From d55d787658c0310d655dfca6cc24a7110f07bbf2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 12 Feb 2026 23:08:44 +0800 Subject: [PATCH 01/11] feat(angular): implement link_component for native Rust linker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ɵɵngDeclareComponent → ɵɵdefineComponent linking in the Rust linker, eliminating the Babel fallback for component declarations. This was the last missing declaration type, so the linker now handles all Angular partial declarations natively. Changes: - Add compile_template_for_linker() and compile_host_bindings_for_linker() in component/transform.rs for template and host binding compilation - Implement link_component() with full support for: template compilation, selectors, inputs/outputs, queries (content + view), host attrs/bindings, features, dependencies, styles, encapsulation, change detection, and animations - Add helper functions: extract_host_metadata_input, extract_dependency_types, build_queries, build_features, get_array_property - Remove Babel fallback from Vite plugin and use async linkAngularPackage - Add tests for component linking including host bindings validation Co-Authored-By: Claude Opus 4.6 --- .../oxc_angular_compiler/src/component/mod.rs | 7 +- .../src/component/transform.rs | 233 +++++ .../src/hmr/update_module.rs | 28 +- crates/oxc_angular_compiler/src/linker/mod.rs | 881 ++++++++++++++++-- .../angular-build-optimizer-plugin.ts | 4 +- .../vite-plugin/angular-linker-plugin.ts | 37 +- napi/angular-compiler/vite-plugin/index.ts | 21 +- 7 files changed, 1117 insertions(+), 94 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 56296bb8b..3a8f6f81b 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -35,7 +35,8 @@ pub use metadata::{ pub use namespace_registry::NamespaceRegistry; pub use transform::{ CompiledComponent, HmrTemplateCompileOutput, HostMetadataInput, ImportInfo, ImportMap, - ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult, build_import_map, - compile_component_template, compile_for_hmr, compile_template_for_hmr, compile_template_to_js, - compile_template_to_js_with_options, transform_angular_file, + LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput, TransformOptions, + TransformResult, build_import_map, compile_component_template, compile_for_hmr, + compile_host_bindings_for_linker, compile_template_for_hmr, compile_template_for_linker, + compile_template_to_js, compile_template_to_js_with_options, transform_angular_file, }; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 926897a9c..2df3f3e9d 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2556,6 +2556,239 @@ fn compile_host_bindings_from_input<'a>( Some(result) } +/// Compile host bindings for the linker, returning the emitted JS function + hostVars count. +/// +/// This takes host property/listener data extracted from a partial declaration and compiles +/// it through the full Angular expression parser and host binding pipeline, producing +/// correctly compiled output (unlike raw string interpolation which would fail for complex +/// Angular template expressions). +pub fn compile_host_bindings_for_linker( + host_input: &HostMetadataInput, + component_name: &str, + selector: Option<&str>, +) -> Option<(String, u32)> { + let allocator = Allocator::default(); + let result = + compile_host_bindings_from_input(&allocator, host_input, component_name, selector)?; + + let emitter = JsEmitter::new(); + + let host_vars = result.host_vars.unwrap_or(0); + + let fn_js = result.host_binding_fn.map(|f| { + let expr = OutputExpression::Function(oxc_allocator::Box::new_in(f, &allocator)); + emitter.emit_expression(&expr) + })?; + + Some((fn_js, host_vars)) +} + +/// Output from compiling a template for the linker. +/// +/// Used by the partial declaration linker to generate `ɵɵdefineComponent` calls +/// from `ɵɵngDeclareComponent` partial declarations. +#[derive(Debug)] +pub struct LinkerTemplateOutput { + /// All declarations (child view functions, pooled constants, main template function) + /// as JavaScript code. These need to be emitted before the `defineComponent` call. + pub declarations_js: String, + + /// The name of the main template function (e.g., "ComponentName_Template"). + pub template_fn_name: String, + + /// Number of element/text/container declarations in the root view. + pub decls: u32, + + /// Number of variable binding slots in the root view. + pub vars: u32, + + /// The consts array as a JavaScript expression string, if any. + pub consts_js: Option, + + /// The ngContentSelectors array as a JavaScript expression string, if any. + pub ng_content_selectors_js: Option, +} + +/// Compile a template for the linker, returning all data needed to build a `defineComponent` call. +/// +/// This is similar to `compile_template_to_js_with_options` but returns a richer result +/// that includes numeric metadata (decls, vars) and the consts/ngContentSelectors as strings, +/// which the linker needs to assemble the `defineComponent({...})` replacement. +pub fn compile_template_for_linker<'a>( + allocator: &'a Allocator, + template: &'a str, + component_name: &str, + file_path: &str, + preserve_whitespaces: bool, +) -> Result> { + use crate::pipeline::ingest::{IngestOptions, ingest_component_with_options}; + use oxc_allocator::FromIn; + + let mut diagnostics = std::vec::Vec::new(); + + // Stage 1: Parse HTML + let parse_options = ParseTemplateOptions { + preserve_whitespaces, + enable_block_syntax: true, + enable_let_syntax: true, + tokenize_expansion_forms: true, + ..Default::default() + }; + let parser = HtmlParser::with_options(allocator, template, file_path, &parse_options); + let html_result = parser.parse(); + + if !html_result.errors.is_empty() { + for error in &html_result.errors { + diagnostics.push(OxcDiagnostic::error(error.msg.clone())); + } + return Err(diagnostics); + } + + // Stage 1.5: Remove whitespace if not preserving + let nodes = if parse_options.preserve_whitespaces { + &html_result.nodes + } else { + let processed = remove_whitespaces(allocator, &html_result.nodes, true); + allocator.alloc(processed) as &_ + }; + + // Stage 2: Transform HTML to R3 AST + let r3_transform_options = + R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes }; + let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); + let r3_result = transformer.transform(nodes); + + if !r3_result.errors.is_empty() { + for error in &r3_result.errors { + diagnostics.push(OxcDiagnostic::error(error.msg.clone())); + } + return Err(diagnostics); + } + + // Stage 3-5: Ingest and compile + let ingest_options = IngestOptions { + mode: TemplateCompilationMode::Full, + relative_context_file_path: None, + i18n_use_external_ids: true, + defer_block_deps_emit_mode: DeferBlockDepsEmitMode::PerBlock, + relative_template_path: None, + enable_debug_locations: false, + template_source: Some(template), + all_deferrable_deps_fn: None, + pool_starting_index: 0, + }; + + let component_name_atom = Atom::from_in(component_name, allocator); + let mut job = ingest_component_with_options( + allocator, + component_name_atom, + r3_result.nodes, + ingest_options, + ); + + let compiled = compile_template(&mut job); + + // Collect diagnostics + diagnostics.extend(job.diagnostics.into_iter()); + + // Extract numeric metadata from the compilation job + let decls = job.root.decl_count.unwrap_or(0); + let vars = job.root.vars.unwrap_or(0); + + let emitter = JsEmitter::new(); + + // Emit consts array as JS expression + let consts_js = if !job.consts.is_empty() { + let mut const_entries: OxcVec<'a, OutputExpression<'a>> = OxcVec::new_in(allocator); + for const_value in &job.consts { + const_entries.push(const_value_to_expression(allocator, const_value)); + } + + let consts_expr = if !job.consts_initializers.is_empty() { + // Wrap in function with initializers + let mut fn_stmts: OxcVec<'a, OutputStatement<'a>> = + OxcVec::with_capacity_in(job.consts_initializers.len() + 1, allocator); + for stmt in job.consts_initializers.drain(..) { + fn_stmts.push(stmt); + } + fn_stmts.push(OutputStatement::Return(oxc_allocator::Box::new_in( + crate::output::ast::ReturnStatement { + value: OutputExpression::LiteralArray(oxc_allocator::Box::new_in( + crate::output::ast::LiteralArrayExpr { + entries: const_entries, + source_span: None, + }, + allocator, + )), + source_span: None, + }, + allocator, + ))); + OutputExpression::Function(oxc_allocator::Box::new_in( + FunctionExpr { + name: None, + params: OxcVec::new_in(allocator), + statements: fn_stmts, + source_span: None, + }, + allocator, + )) + } else { + OutputExpression::LiteralArray(oxc_allocator::Box::new_in( + crate::output::ast::LiteralArrayExpr { entries: const_entries, source_span: None }, + allocator, + )) + }; + Some(emitter.emit_expression(&consts_expr)) + } else { + None + }; + + // Emit ngContentSelectors as JS expression + let ng_content_selectors_js = + job.content_selectors.take().map(|expr| emitter.emit_expression(&expr)); + + // Get template function name + let template_fn_name = compiled + .template_fn + .name + .as_ref() + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("{component_name}_Template")); + + // Emit all declarations + main template function as JS code + let mut all_statements: OxcVec<'a, OutputStatement<'a>> = OxcVec::new_in(allocator); + + for decl in compiled.declarations { + all_statements.push(decl); + } + + if let Some(fn_name) = compiled.template_fn.name.clone() { + let main_fn_stmt = OutputStatement::DeclareFunction(oxc_allocator::Box::new_in( + DeclareFunctionStmt { + name: fn_name, + params: compiled.template_fn.params, + statements: compiled.template_fn.statements, + modifiers: StmtModifier::NONE, + source_span: compiled.template_fn.source_span, + }, + allocator, + )); + all_statements.push(main_fn_stmt); + } + + let declarations_js = emitter.emit_statements(&all_statements); + + Ok(LinkerTemplateOutput { + declarations_js, + template_fn_name, + decls, + vars, + consts_js, + ng_content_selectors_js, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/oxc_angular_compiler/src/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index 3aaff6945..6f8ed066d 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -146,9 +146,15 @@ fn generate_hmr_update_module_internal( } // Update the component definition using ɵɵdefineComponent - // We spread the existing definition and override template/styles + // We spread the existing definition and override template/styles. + // IMPORTANT: We must override `inputs` with `inputConfig` because the spread + // includes `inputs` in the already-processed format (output of + // `parseAndConvertInputsForDefinition`). If we don't override, ɵɵdefineComponent + // will process them again, producing corrupted input mappings. + // `inputConfig` stores the original unprocessed inputs format. output.push_str(&format!(" {}.ɵcmp = i0.ɵɵdefineComponent({{\n", class_name)); output.push_str(&format!(" ...{}.ɵcmp,\n", class_name)); + output.push_str(&format!(" inputs: {}.ɵcmp.inputConfig,\n", class_name)); // Add template function if present if let Some(template_js) = template_js { @@ -255,6 +261,26 @@ mod tests { assert!(decl_pos < cmp_pos); } + #[test] + fn test_generate_hmr_update_module_uses_input_config() { + let result = generate_hmr_update_module_from_js( + "src/app/app.component.ts@AppComponent", + "function AppComponent_Template(rf, ctx) { }", + None, + None, + None, + ); + + // The HMR module must override `inputs` with `inputConfig` to avoid + // double-processing by `parseAndConvertInputsForDefinition`. + assert!(result.contains("inputs: AppComponent.ɵcmp.inputConfig")); + + // `inputs` override must come AFTER the spread to take precedence + let spread_pos = result.find("...AppComponent.ɵcmp").unwrap(); + let inputs_pos = result.find("inputs: AppComponent.ɵcmp.inputConfig").unwrap(); + assert!(inputs_pos > spread_pos); + } + #[test] fn test_generate_hmr_update_module_with_consts() { let result = generate_hmr_update_module_from_js( diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 85b7b0fc4..3b2025cea 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -34,8 +34,8 @@ use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, CallExpression, Expression, ObjectExpression, ObjectPropertyKind, Program, - PropertyKey, Statement, + Argument, ArrayExpressionElement, CallExpression, Expression, ObjectExpression, + ObjectPropertyKind, Program, PropertyKey, Statement, }; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; @@ -85,7 +85,7 @@ pub fn link(allocator: &Allocator, code: &str, filename: &str) -> LinkResult { let mut edits: Vec = Vec::new(); // Walk all statements looking for ɵɵngDeclare* calls - collect_declaration_edits(&program, code, &mut edits); + collect_declaration_edits(&program, code, filename, &mut edits); if edits.is_empty() { return LinkResult { code: code.to_string(), map: None, linked: false }; @@ -97,110 +97,115 @@ pub fn link(allocator: &Allocator, code: &str, filename: &str) -> LinkResult { } /// Recursively walk the AST to find all ɵɵngDeclare* calls and generate edits. -fn collect_declaration_edits(program: &Program<'_>, source: &str, edits: &mut Vec) { +fn collect_declaration_edits( + program: &Program<'_>, + source: &str, + filename: &str, + edits: &mut Vec, +) { for stmt in &program.body { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } /// Walk a statement looking for ɵɵngDeclare* calls. -fn walk_statement(stmt: &Statement<'_>, source: &str, edits: &mut Vec) { +fn walk_statement(stmt: &Statement<'_>, source: &str, filename: &str, edits: &mut Vec) { match stmt { Statement::ExpressionStatement(expr_stmt) => { - walk_expression(&expr_stmt.expression, source, edits); + walk_expression(&expr_stmt.expression, source, filename, edits); } Statement::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, edits); + walk_class_body(&class_decl.body, source, filename, edits); } Statement::VariableDeclaration(var_decl) => { for decl in &var_decl.declarations { if let Some(init) = &decl.init { - walk_expression(init, source, edits); + walk_expression(init, source, filename, edits); } } } Statement::ReturnStatement(ret) => { if let Some(ref arg) = ret.argument { - walk_expression(arg, source, edits); + walk_expression(arg, source, filename, edits); } } Statement::BlockStatement(block) => { for stmt in &block.body { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } Statement::IfStatement(if_stmt) => { - walk_statement(&if_stmt.consequent, source, edits); + walk_statement(&if_stmt.consequent, source, filename, edits); if let Some(ref alt) = if_stmt.alternate { - walk_statement(alt, source, edits); + walk_statement(alt, source, filename, edits); } } Statement::ForStatement(for_stmt) => { - walk_statement(&for_stmt.body, source, edits); + walk_statement(&for_stmt.body, source, filename, edits); } Statement::ForInStatement(for_in) => { - walk_statement(&for_in.body, source, edits); + walk_statement(&for_in.body, source, filename, edits); } Statement::ForOfStatement(for_of) => { - walk_statement(&for_of.body, source, edits); + walk_statement(&for_of.body, source, filename, edits); } Statement::WhileStatement(while_stmt) => { - walk_statement(&while_stmt.body, source, edits); + walk_statement(&while_stmt.body, source, filename, edits); } Statement::DoWhileStatement(do_while) => { - walk_statement(&do_while.body, source, edits); + walk_statement(&do_while.body, source, filename, edits); } Statement::TryStatement(try_stmt) => { for stmt in &try_stmt.block.body { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } if let Some(ref handler) = try_stmt.handler { for stmt in &handler.body.body { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } if let Some(ref finalizer) = try_stmt.finalizer { for stmt in &finalizer.body { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } } Statement::SwitchStatement(switch_stmt) => { for case in &switch_stmt.cases { for stmt in &case.consequent { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } } Statement::LabeledStatement(labeled) => { - walk_statement(&labeled.body, source, edits); + walk_statement(&labeled.body, source, filename, edits); } Statement::FunctionDeclaration(func_decl) => { if let Some(ref body) = func_decl.body { for stmt in &body.statements { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } } Statement::ExportNamedDeclaration(export_decl) => { if let Some(ref decl) = export_decl.declaration { - walk_declaration(decl, source, edits); + walk_declaration(decl, source, filename, edits); } } Statement::ExportDefaultDeclaration(export_default) => match &export_default.declaration { oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, edits); + walk_class_body(&class_decl.body, source, filename, 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, edits); + walk_statement(stmt, source, filename, edits); } } } _ => { if let Some(expr) = export_default.declaration.as_expression() { - walk_expression(expr, source, edits); + walk_expression(expr, source, filename, edits); } } }, @@ -209,22 +214,27 @@ fn walk_statement(stmt: &Statement<'_>, source: &str, edits: &mut Vec) { } /// 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, edits: &mut Vec) { +fn walk_class_body( + body: &oxc_ast::ast::ClassBody<'_>, + source: &str, + filename: &str, + edits: &mut Vec, +) { for element in &body.body { if let oxc_ast::ast::ClassElement::PropertyDefinition(prop) = element { if let Some(ref value) = prop.value { - walk_expression(value, source, edits); + walk_expression(value, source, filename, edits); } } if let oxc_ast::ast::ClassElement::StaticBlock(block) = element { for stmt in &block.body { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } if let oxc_ast::ast::ClassElement::MethodDefinition(method) = element { if let Some(ref body) = method.value.body { for stmt in &body.statements { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } } @@ -232,35 +242,40 @@ fn walk_class_body(body: &oxc_ast::ast::ClassBody<'_>, source: &str, edits: &mut } /// Walk a declaration (from export statements) looking for ɵɵngDeclare* calls. -fn walk_declaration(decl: &oxc_ast::ast::Declaration<'_>, source: &str, edits: &mut Vec) { +fn walk_declaration( + decl: &oxc_ast::ast::Declaration<'_>, + source: &str, + filename: &str, + 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, edits); + walk_expression(init, source, filename, 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, edits); + walk_statement(stmt, source, filename, edits); } } } oxc_ast::ast::Declaration::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, edits); + walk_class_body(&class_decl.body, source, filename, edits); } _ => {} } } /// Walk an expression looking for ɵɵngDeclare* calls. -fn walk_expression(expr: &Expression<'_>, source: &str, edits: &mut Vec) { +fn walk_expression(expr: &Expression<'_>, source: &str, filename: &str, edits: &mut Vec) { match expr { Expression::CallExpression(call) => { if let Some(name) = get_declare_name(call) { - if let Some(edit) = link_declaration(name, call, source) { + if let Some(edit) = link_declaration(name, call, source, filename) { edits.push(edit); return; } @@ -270,42 +285,42 @@ fn walk_expression(expr: &Expression<'_>, source: &str, edits: &mut Vec) { if let Argument::SpreadElement(_) = arg { continue; } - walk_expression(arg.to_expression(), source, edits); + walk_expression(arg.to_expression(), source, filename, edits); } } Expression::AssignmentExpression(assign) => { - walk_expression(&assign.right, source, edits); + walk_expression(&assign.right, source, filename, edits); } Expression::SequenceExpression(seq) => { for expr in &seq.expressions { - walk_expression(expr, source, edits); + walk_expression(expr, source, filename, edits); } } Expression::ConditionalExpression(cond) => { - walk_expression(&cond.consequent, source, edits); - walk_expression(&cond.alternate, source, edits); + walk_expression(&cond.consequent, source, filename, edits); + walk_expression(&cond.alternate, source, filename, edits); } Expression::LogicalExpression(logical) => { - walk_expression(&logical.left, source, edits); - walk_expression(&logical.right, source, edits); + walk_expression(&logical.left, source, filename, edits); + walk_expression(&logical.right, source, filename, edits); } Expression::ParenthesizedExpression(paren) => { - walk_expression(&paren.expression, source, edits); + walk_expression(&paren.expression, source, filename, edits); } Expression::ArrowFunctionExpression(arrow) => { for stmt in &arrow.body.statements { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } Expression::FunctionExpression(func) => { if let Some(body) = &func.body { for stmt in &body.statements { - walk_statement(stmt, source, edits); + walk_statement(stmt, source, filename, edits); } } } Expression::ClassExpression(class_expr) => { - walk_class_body(&class_expr.body, source, edits); + walk_class_body(&class_expr.body, source, filename, edits); } _ => {} } @@ -646,7 +661,12 @@ fn get_factory_target(obj: &ObjectExpression<'_>, source: &str) -> &'static str } /// Link a single ɵɵngDeclare* call, generating the replacement code. -fn link_declaration(name: &str, call: &CallExpression<'_>, source: &str) -> Option { +fn link_declaration( + name: &str, + call: &CallExpression<'_>, + source: &str, + filename: &str, +) -> Option { let meta = get_metadata_object(call)?; let ns = get_ng_import_namespace(call); let type_name = get_property_source(meta, "type", source)?; @@ -660,11 +680,7 @@ fn link_declaration(name: &str, call: &CallExpression<'_>, source: &str) -> Opti DECLARE_CLASS_METADATA => link_class_metadata(meta, source, ns, type_name), DECLARE_CLASS_METADATA_ASYNC => link_class_metadata_async(meta, source, ns, type_name), DECLARE_DIRECTIVE => link_directive(meta, source, ns, type_name), - // Skip component linking: template compilation is not yet implemented. - // Replacing ɵɵngDeclareComponent with an empty template would silently break - // all library component rendering. Leave the partial declaration intact so - // Angular's runtime can JIT-compile it via @angular/compiler. - DECLARE_COMPONENT => return None, + DECLARE_COMPONENT => link_component(meta, source, filename, ns, type_name), _ => return None, }; @@ -888,6 +904,111 @@ fn link_class_metadata_async( )) } +/// Convert inputs from declaration format to definition format. +/// +/// Declaration format (`ɵɵngDeclareDirective`): +/// - `propertyName: "publicName"` (simple) +/// - `propertyName: ["publicName", "classPropertyName"]` (aliased) +/// - `propertyName: { classPropertyName: "...", publicName: "...", isRequired: bool, +/// isSignal: bool, transformFunction: expr }` (Angular 16+ object format) +/// +/// Definition format (`ɵɵdefineDirective`): +/// - `propertyName: "publicName"` (simple, same as declaration) +/// - `propertyName: [InputFlags, "publicName", "declaredName", transform?]` (array format) +/// +/// InputFlags: None=0, SignalBased=1, HasDecoratorInputTransform=2 +fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source: &str) -> String { + let mut entries: Vec = Vec::new(); + + for prop in &inputs_obj.properties { + let ObjectPropertyKind::ObjectProperty(p) = prop else { continue }; + + let key = match &p.key { + PropertyKey::StaticIdentifier(ident) => ident.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => { + // Fallback: use source text + let span = p.span(); + entries.push(source[span.start as usize..span.end as usize].to_string()); + continue; + } + }; + + match &p.value { + // Simple string: propertyName: "publicName" → keep as is + Expression::StringLiteral(lit) => { + entries.push(format!("{key}: \"{}\"", lit.value)); + } + // Array: check if it's declaration format [publicName, classPropertyName] + // and convert to definition format [InputFlags, publicName, classPropertyName] + Expression::ArrayExpression(arr) => { + if arr.elements.len() == 2 { + // Check if first element is a string (declaration format) + let first_is_string = matches!( + arr.elements.first(), + Some(ArrayExpressionElement::StringLiteral(_)) + ); + if first_is_string { + // Declaration format: ["publicName", "classPropertyName"] + // Convert to: [0, "publicName", "classPropertyName"] + let arr_source = + &source[arr.span.start as usize + 1..arr.span.end as usize - 1]; + entries.push(format!("{key}: [0, {arr_source}]")); + } else { + // Already in definition format or unknown, keep as is + let val = + &source[p.value.span().start as usize..p.value.span().end as usize]; + entries.push(format!("{key}: {val}")); + } + } else { + // 3+ elements likely already in definition format, keep as is + let val = &source[p.value.span().start as usize..p.value.span().end as usize]; + entries.push(format!("{key}: {val}")); + } + } + // Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc. + Expression::ObjectExpression(obj) => { + let public_name = get_string_property(obj, "publicName").unwrap_or(&key); + let declared_name = + get_string_property(obj, "classPropertyName").unwrap_or(public_name); + let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false); + let is_required = get_bool_property(obj, "isRequired").unwrap_or(false); + let transform = get_property_source(obj, "transformFunction", source); + + let mut flags = 0u32; + if is_signal { + flags |= 1; // InputFlags.SignalBased + } + if transform.is_some() { + flags |= 2; // InputFlags.HasDecoratorInputTransform + } + // isRequired is expressed via InputFlags.SignalBased for signal inputs + // and is checked separately for non-signal inputs + let _ = is_required; + + if flags == 0 && transform.is_none() && public_name == declared_name { + // Simple case: no flags, no transform, names match + entries.push(format!("{key}: \"{public_name}\"")); + } else if let Some(transform_fn) = transform { + entries.push(format!( + "{key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]" + )); + } else { + entries + .push(format!("{key}: [{flags}, \"{public_name}\", \"{declared_name}\"]")); + } + } + // Unknown format, keep as is + _ => { + let val = &source[p.value.span().start as usize..p.value.span().end as usize]; + entries.push(format!("{key}: {val}")); + } + } + } + + format!("{{ {} }}", entries.join(", ")) +} + /// Link ɵɵngDeclareDirective → ɵɵdefineDirective. fn link_directive( meta: &ObjectExpression<'_>, @@ -900,8 +1021,9 @@ fn link_directive( if let Some(selector) = get_string_property(meta, "selector") { parts.push(format!("selectors: {}", parse_selector(selector))); } - if let Some(inputs) = get_property_source(meta, "inputs", source) { - parts.push(format!("inputs: {inputs}")); + if let Some(inputs_obj) = get_object_property(meta, "inputs") { + let converted = convert_inputs_to_definition_format(inputs_obj, source); + parts.push(format!("inputs: {converted}")); } if let Some(outputs) = get_property_source(meta, "outputs", source) { parts.push(format!("outputs: {outputs}")); @@ -930,12 +1052,409 @@ fn link_directive( Some(format!("{ns}.\u{0275}\u{0275}defineDirective({{ {} }})", parts.join(", "))) } -// NOTE: link_component is intentionally not implemented. -// Component linking requires full template compilation (parsing HTML templates -// into Angular instruction sequences like ɵɵelementStart, ɵɵtext, etc.). -// This is a major feature that needs a template compiler. -// Until implemented, ɵɵngDeclareComponent is left intact for Angular's -// runtime JIT compiler to handle. +/// Extract an array expression property value from an object expression. +fn get_array_property<'a>( + obj: &'a ObjectExpression<'a>, + name: &str, +) -> Option<&'a oxc_ast::ast::ArrayExpression<'a>> { + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) { + if let Expression::ArrayExpression(arr) = &prop.value { + return Some(arr.as_ref()); + } + } + } + } + None +} + +/// Extract dependency type references from the `dependencies` array in component metadata. +/// +/// In partial declarations, dependencies look like: +/// ```javascript +/// dependencies: [{ kind: "directive", type: RouterOutlet, selector: "...", ... }] +/// ``` +/// Extract host properties and listeners from the `host` metadata object into a +/// `HostMetadataInput` for compilation through the full Angular expression parser. +/// +/// The partial declaration format stores host bindings as: +/// ```javascript +/// host: { +/// properties: { "id": "this.dirId", "attr.aria-disabled": "disabled" }, +/// listeners: { "click": "onClick($event)" } +/// } +/// ``` +/// +/// The values are Angular template expression strings that must be compiled through +/// the Angular expression parser (not simple string interpolation). +fn extract_host_metadata_input( + host_obj: &ObjectExpression<'_>, +) -> crate::component::HostMetadataInput { + let mut input = crate::component::HostMetadataInput::default(); + + if let Some(properties) = get_object_property(host_obj, "properties") { + for prop in &properties.properties { + let ObjectPropertyKind::ObjectProperty(p) = prop else { continue }; + let key = match &p.key { + PropertyKey::StaticIdentifier(ident) => ident.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + let value = match &p.value { + Expression::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + input.properties.push((key, value)); + } + } + + if let Some(listeners) = get_object_property(host_obj, "listeners") { + for prop in &listeners.properties { + let ObjectPropertyKind::ObjectProperty(p) = prop else { continue }; + let key = match &p.key { + PropertyKey::StaticIdentifier(ident) => ident.name.to_string(), + PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + let value = match &p.value { + Expression::StringLiteral(s) => s.value.to_string(), + _ => continue, + }; + input.listeners.push((key, value)); + } + } + + input +} + +/// In the defineComponent output, we just need the type references: +/// ```javascript +/// dependencies: [RouterOutlet] +/// ``` +fn extract_dependency_types( + arr: &oxc_ast::ast::ArrayExpression<'_>, + source: &str, +) -> Option { + let mut types: Vec = Vec::new(); + for el in &arr.elements { + let expr = match el { + ArrayExpressionElement::SpreadElement(_) => continue, + _ => el.to_expression(), + }; + if let Expression::ObjectExpression(obj) = expr { + if let Some(type_src) = get_property_source(obj.as_ref(), "type", source) { + types.push(type_src.to_string()); + } + } + } + if types.is_empty() { None } else { Some(format!("[{}]", types.join(", "))) } +} + +/// Build a query function (contentQueries or viewQuery) from query metadata. +/// +/// Content query metadata format: +/// ```javascript +/// { propertyName: "items", first: true, predicate: SomeType, descendants: true } +/// ``` +/// +/// View query metadata format: +/// ```javascript +/// { propertyName: "child", first: true, predicate: SomeType, static: true } +/// ``` +fn build_queries( + queries: &oxc_ast::ast::ArrayExpression<'_>, + source: &str, + ns: &str, + type_name: &str, + is_content_query: bool, +) -> Option { + if queries.elements.is_empty() { + return None; + } + + let mut create_stmts: Vec = Vec::new(); + let mut update_stmts: Vec = Vec::new(); + let mut t_declared = false; + + for el in &queries.elements { + let expr = match el { + ArrayExpressionElement::SpreadElement(_) => continue, + _ => el.to_expression(), + }; + let Expression::ObjectExpression(query_obj) = expr else { continue }; + + let prop_name = + get_string_property(query_obj.as_ref(), "propertyName").unwrap_or("unknown"); + let first = get_bool_property(query_obj.as_ref(), "first").unwrap_or(false); + let is_static = get_bool_property(query_obj.as_ref(), "static").unwrap_or(false); + let descendants = get_bool_property(query_obj.as_ref(), "descendants").unwrap_or(false); + let is_signal = get_bool_property(query_obj.as_ref(), "isSignal").unwrap_or(false); + let read = get_property_source(query_obj.as_ref(), "read", source); + + // Build predicate - can be a type reference or string array + let predicate = + get_property_source(query_obj.as_ref(), "predicate", source).unwrap_or("null"); + + // Calculate flags + let flags = if is_content_query { + if descendants { 5u32 } else { 4u32 } + } else if is_static { + 7u32 + } else { + 4u32 + }; + + // Signal queries use different flags + let flags = if is_signal { flags | 1 } else { flags }; + + // Create block + if is_content_query { + let mut args = format!("dirIndex, {predicate}, {flags}"); + if let Some(read_expr) = read { + args = format!("{args}, {read_expr}"); + } + create_stmts.push(format!("{ns}.\u{0275}\u{0275}contentQuery({args})")); + } else { + let mut args = format!("{predicate}, {flags}"); + if let Some(read_expr) = read { + args = format!("{args}, {read_expr}"); + } + create_stmts.push(format!("{ns}.\u{0275}\u{0275}viewQuery({args})")); + } + + // Update block — declare `_t` once before the first query refresh + let t_var = if !t_declared { + t_declared = true; + "let _t;\n" + } else { + "" + }; + let access = if first { ".first" } else { "" }; + update_stmts.push(format!( + "{t_var}{ns}.\u{0275}\u{0275}queryRefresh(_t = {ns}.\u{0275}\u{0275}loadQuery()) && (ctx.{prop_name} = _t{access})" + )); + } + + let create_block = create_stmts.join(";\n"); + let update_block = update_stmts.join(";\n"); + + if is_content_query { + Some(format!( + "function {type_name}_ContentQueries(rf, ctx, dirIndex) {{\nif (rf & 1) {{\n{create_block};\n}}\nif (rf & 2) {{\n{update_block};\n}}\n}}" + )) + } else { + Some(format!( + "function {type_name}_Query(rf, ctx) {{\nif (rf & 1) {{\n{create_block};\n}}\nif (rf & 2) {{\n{update_block};\n}}\n}}" + )) + } +} + +/// Build the features array from component metadata. +/// +/// Examines boolean flags and providers to build the features array: +/// - `usesInheritance: true` → `ns.ɵɵInheritDefinitionFeature` +/// - `usesOnChanges: true` → `ns.ɵɵNgOnChangesFeature` +/// - `providers: [...]` → `ns.ɵɵProvidersFeature([...])` +/// - `viewProviders: [...]` → `ns.ɵɵViewProvidersFeature([...])` +fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option { + let mut features: Vec = Vec::new(); + + if get_bool_property(meta, "usesInheritance") == Some(true) { + features.push(format!("{ns}.\u{0275}\u{0275}InheritDefinitionFeature")); + } + if get_bool_property(meta, "usesOnChanges") == Some(true) { + features.push(format!("{ns}.\u{0275}\u{0275}NgOnChangesFeature")); + } + if let Some(providers) = get_property_source(meta, "providers", source) { + features.push(format!("{ns}.\u{0275}\u{0275}ProvidersFeature({providers})")); + } + if let Some(view_providers) = get_property_source(meta, "viewProviders", source) { + features.push(format!("{ns}.\u{0275}\u{0275}ViewProvidersFeature({view_providers})")); + } + + if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) } +} + +/// Link ɵɵngDeclareComponent → ɵɵdefineComponent. +/// +/// A component extends a directive with template compilation and additional +/// component-specific metadata (styles, encapsulation, change detection, etc.). +/// +/// The replacement is wrapped in an IIFE to scope the template function declarations: +/// ```javascript +/// (() => { +/// function Child_Template(rf, ctx) { ... } +/// function Component_Template(rf, ctx) { ... } +/// return i0.ɵɵdefineComponent({ ... template: Component_Template, ... }); +/// })() +/// ``` +fn link_component( + meta: &ObjectExpression<'_>, + source: &str, + filename: &str, + ns: &str, + type_name: &str, +) -> Option { + // Extract template string - required for component linking + let template = get_string_property(meta, "template")?; + let preserve_whitespaces = get_bool_property(meta, "preserveWhitespaces").unwrap_or(false); + + // Compile the template using the full template compilation pipeline. + let template_allocator = Allocator::default(); + + // We need to leak the template string into the template allocator's lifetime + let template_owned: String = template.to_string(); + let template_ref: &str = template_allocator.alloc_str(&template_owned); + + let template_output = crate::component::compile_template_for_linker( + &template_allocator, + template_ref, + type_name, + filename, + preserve_whitespaces, + ) + .ok()?; + + // Build the defineComponent properties + let mut parts: Vec = Vec::new(); + + // 1. type + parts.push(format!("type: {type_name}")); + + // 2. selectors + if let Some(selector) = get_string_property(meta, "selector") { + parts.push(format!("selectors: {}", parse_selector(selector))); + } + + // 3. contentQueries + if let Some(queries_arr) = get_array_property(meta, "queries") { + if let Some(cq_fn) = build_queries(queries_arr, source, ns, type_name, true) { + parts.push(format!("contentQueries: {cq_fn}")); + } + } + + // 4. viewQuery + if let Some(view_queries_arr) = get_array_property(meta, "viewQueries") { + if let Some(vq_fn) = build_queries(view_queries_arr, source, ns, type_name, false) { + parts.push(format!("viewQuery: {vq_fn}")); + } + } + + // 5-7. Host bindings (hostAttrs, hostVars, hostBindings) + if let Some(host_obj) = get_object_property(meta, "host") { + // Static attributes → hostAttrs + let host_attrs = build_host_attrs(host_obj, source); + if !host_attrs.is_empty() { + parts.push(format!("hostAttrs: [{host_attrs}]")); + } + + // Dynamic bindings → hostVars + hostBindings function + // Extract host properties and listeners as HostMetadataInput and compile + // through the full Angular expression parser for correct output. + let host_input = extract_host_metadata_input(host_obj); + let selector = get_string_property(meta, "selector"); + if let Some((host_fn, host_vars)) = + crate::component::compile_host_bindings_for_linker(&host_input, type_name, selector) + { + if host_vars > 0 { + parts.push(format!("hostVars: {host_vars}")); + } + parts.push(format!("hostBindings: {host_fn}")); + } + } + + // 8. inputs + if let Some(inputs_obj) = get_object_property(meta, "inputs") { + let converted = convert_inputs_to_definition_format(inputs_obj, source); + parts.push(format!("inputs: {converted}")); + } + + // 9. outputs + if let Some(outputs) = get_property_source(meta, "outputs", source) { + parts.push(format!("outputs: {outputs}")); + } + + // 10. exportAs + if let Some(export_as) = get_property_source(meta, "exportAs", source) { + parts.push(format!("exportAs: {export_as}")); + } + + // 11. standalone + let standalone = get_bool_property(meta, "isStandalone").unwrap_or(true); + parts.push(format!("standalone: {standalone}")); + + // 12. features + if let Some(features) = build_features(meta, source, ns) { + parts.push(format!("features: {features}")); + } + + // 13. ngContentSelectors (from template compilation) + if let Some(ref ng_content_selectors) = template_output.ng_content_selectors_js { + parts.push(format!("ngContentSelectors: {ng_content_selectors}")); + } + + // 14. decls (from template compilation) + parts.push(format!("decls: {}", template_output.decls)); + + // 15. vars (from template compilation) + parts.push(format!("vars: {}", template_output.vars)); + + // 16. consts (from template compilation) + if let Some(ref consts) = template_output.consts_js { + parts.push(format!("consts: {consts}")); + } + + // 17. template (reference to the compiled function) + parts.push(format!("template: {}", template_output.template_fn_name)); + + // 18. dependencies (extract type references from dependency objects) + if let Some(deps_arr) = get_array_property(meta, "dependencies") { + if let Some(deps_str) = extract_dependency_types(deps_arr, source) { + parts.push(format!("dependencies: {deps_str}")); + } + } + + // 19. styles + if let Some(styles) = get_property_source(meta, "styles", source) { + parts.push(format!("styles: {styles}")); + } + + // 20. encapsulation + if let Some(encap) = get_property_source(meta, "encapsulation", source) { + // Convert ViewEncapsulation enum to numeric value + if encap.contains("None") { + parts.push("encapsulation: 2".to_string()); + } else if encap.contains("ShadowDom") { + parts.push("encapsulation: 3".to_string()); + } + // Emulated (0) is the default, no need to emit + } + + // 21. data (animations) + if let Some(animations) = get_property_source(meta, "animations", source) { + parts.push(format!("data: {{ animation: {animations} }}")); + } + + // 22. changeDetection + if let Some(cd) = get_property_source(meta, "changeDetection", source) { + if cd.contains("OnPush") { + parts.push("changeDetection: 0".to_string()); + } + // Default (1) is the default, no need to emit + } + + let define_component = + format!("{ns}.\u{0275}\u{0275}defineComponent({{ {} }})", parts.join(", ")); + + // Wrap in IIFE with template declarations + let declarations = &template_output.declarations_js; + if declarations.trim().is_empty() { + Some(define_component) + } else { + Some(format!("(() => {{\n{declarations}\nreturn {define_component};\n}})()")) + } +} #[cfg(test)] mod tests { @@ -1040,7 +1559,36 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.0", ngImpor } #[test] - fn test_component_declarations_are_preserved() { + fn test_link_directive_with_aliased_inputs() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class RxFor { +} +RxFor.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.2.10", ngImport: i0, type: RxFor, isStandalone: true, selector: "[rxFor][rxForOf]", inputs: { rxForOf: "rxForOf", renderParent: ["rxForParent", "renderParent"], trackBy: ["rxForTrackBy", "trackBy"] } }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("defineDirective")); + assert!(!result.code.contains("ɵɵngDeclareDirective")); + // Simple inputs should stay as string: rxForOf: "rxForOf" + assert!(result.code.contains(r#"rxForOf: "rxForOf""#)); + // Aliased inputs must be converted with InputFlags prepended: + // ["rxForTrackBy", "trackBy"] → [0, "rxForTrackBy", "trackBy"] + assert!( + result.code.contains(r#"trackBy: [0, "rxForTrackBy", "trackBy"]"#), + "Expected trackBy to have InputFlags prepended. Got: {}", + result.code + ); + assert!( + result.code.contains(r#"renderParent: [0, "rxForParent", "renderParent"]"#), + "Expected renderParent to have InputFlags prepended. Got: {}", + result.code + ); + } + + #[test] + fn test_link_component_basic() { let allocator = Allocator::default(); let code = r#" import * as i0 from "@angular/core"; @@ -1049,16 +1597,31 @@ class MyComponent { MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
Hello
" }); "#; let result = link(&allocator, code, "test.mjs"); - // Component declarations should NOT be linked (template compilation not implemented). - // The original ɵɵngDeclareComponent call must be preserved intact so Angular's - // runtime can JIT-compile the template. - assert!(!result.linked); - assert!(result.code.contains("\u{0275}\u{0275}ngDeclareComponent")); - assert!(!result.code.contains("defineComponent")); + assert!(result.linked, "Component should be linked"); + assert!( + result.code.contains("defineComponent"), + "Should contain defineComponent, got:\n{}", + result.code + ); + assert!( + !result.code.contains("\u{0275}\u{0275}ngDeclareComponent"), + "Should not contain ngDeclareComponent, got:\n{}", + result.code + ); + assert!( + result.code.contains("MyComponent_Template"), + "Should contain compiled template function, got:\n{}", + result.code + ); + assert!( + result.code.contains("selectors: [[\"my-comp\"]]"), + "Should contain parsed selectors, got:\n{}", + result.code + ); } #[test] - fn test_component_preserved_while_other_declarations_linked() { + fn test_link_component_with_factory() { let allocator = Allocator::default(); let code = r#" import * as i0 from "@angular/core"; @@ -1068,10 +1631,182 @@ MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20 MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
Hello
" }); "#; let result = link(&allocator, code, "test.mjs"); - // Factory should be linked but component declaration should be preserved assert!(result.linked); assert!(result.code.contains("MyComponent_Factory")); assert!(!result.code.contains("\u{0275}\u{0275}ngDeclareFactory")); - assert!(result.code.contains("\u{0275}\u{0275}ngDeclareComponent")); + assert!(result.code.contains("defineComponent")); + assert!(!result.code.contains("\u{0275}\u{0275}ngDeclareComponent")); + } + + #[test] + fn test_link_component_with_dependencies() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +import * as i1 from "@angular/router"; +class EmptyOutletComponent { +} +EmptyOutletComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: EmptyOutletComponent, selector: "ng-component", template: "", isStandalone: true, dependencies: [{ kind: "directive", type: i1.RouterOutlet, selector: "router-outlet" }] }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked, "Should be linked"); + assert!( + result.code.contains("defineComponent"), + "Should contain defineComponent, got:\n{}", + result.code + ); + assert!( + result.code.contains("dependencies: [i1.RouterOutlet]"), + "Should extract dependency types, got:\n{}", + result.code + ); + } + + #[test] + fn test_link_component_with_features() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
", usesInheritance: true, providers: [SomeService] }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!( + result.code.contains("InheritDefinitionFeature"), + "Should have InheritDefinitionFeature, got:\n{}", + result.code + ); + assert!( + result.code.contains("ProvidersFeature"), + "Should have ProvidersFeature, got:\n{}", + result.code + ); + } + + #[test] + fn test_link_component_with_ng_content() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class CdkStep { +} +CdkStep.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: CdkStep, selector: "cdk-step", template: "", isStandalone: true }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!( + result.code.contains("defineComponent"), + "Should contain defineComponent, got:\n{}", + result.code + ); + assert!( + result.code.contains("ngContentSelectors"), + "Should contain ngContentSelectors for ng-content, got:\n{}", + result.code + ); + } + + #[test] + fn test_link_component_with_encapsulation() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
", encapsulation: i0.ViewEncapsulation.None }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!( + result.code.contains("encapsulation: 2"), + "ViewEncapsulation.None should be 2, got:\n{}", + result.code + ); + } + + #[test] + fn test_link_component_with_change_detection() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
", changeDetection: i0.ChangeDetectionStrategy.OnPush }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!( + result.code.contains("changeDetection: 0"), + "ChangeDetectionStrategy.OnPush should be 0, got:\n{}", + result.code + ); + } + + #[test] + fn test_link_component_with_host_attrs() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
", host: { attributes: { "role": "tree" }, classAttribute: "cdk-tree" } }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!( + result.code.contains("hostAttrs:"), + "Should contain hostAttrs, got:\n{}", + result.code + ); + assert!( + result.code.contains("\"role\""), + "Should contain role attribute, got:\n{}", + result.code + ); + } + + #[test] + fn test_link_component_with_host_bindings() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComponent { +} +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComponent, selector: "my-comp", template: "
", host: { properties: { "id": "this.dirId", "attr.aria-disabled": "disabled" }, listeners: { "click": "onClick($event)" } } }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + // Should have hostVars for the 2 property bindings + assert!( + result.code.contains("hostVars:"), + "Should contain hostVars, got:\n{}", + result.code + ); + // Should have hostBindings function + assert!( + result.code.contains("hostBindings:"), + "Should contain hostBindings, got:\n{}", + result.code + ); + // The host binding function should properly compile expressions, not raw strings with quotes + assert!( + !result.code.contains(r#"ctx."this.dirId""#), + "Should NOT contain invalid ctx.\"this.dirId\" expression, got:\n{}", + result.code + ); + // Should have proper context property access + assert!( + result.code.contains("ctx.dirId"), + "Should contain properly compiled ctx.dirId, got:\n{}", + result.code + ); + // Listener should be properly compiled (not raw string with quotes) + assert!( + !result.code.contains(r#"ctx."onClick($event)""#), + "Should NOT contain invalid listener expression, got:\n{}", + result.code + ); } } diff --git a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts index 44d780956..bee481ae0 100644 --- a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts @@ -53,8 +53,8 @@ export function buildOptimizerPlugin({ }, transform: { filter: { - // Match Angular FESM packages (e.g., @angular/core/fesm2022/core.mjs) - id: /node_modules\/@angular\/.*fesm20.*\.[cm]?js$/, + // Match Angular FESM packages (e.g., @angular/core/fesm2022/core.mjs, @ngrx/store/fesm2022/ngrx-store.mjs) + id: /fesm20.*\.[cm]?js$/, }, async handler(code, id) { // Only optimize in production builds diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts index cf670b262..d429a9794 100644 --- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises' -import { linkAngularPackageSync } from '#binding' +import { linkAngularPackage } from '#binding' import type { Plugin } from 'vite' /** @@ -13,7 +13,8 @@ import type { Plugin } from 'vite' * Without this plugin, Angular falls back to JIT compilation which requires * @angular/compiler at runtime. * - * Uses OXC's native Rust-based linker for fast, zero-dependency linking. + * Uses OXC's native Rust-based linker for fast, zero-dependency linking of all + * declaration types including ɵɵngDeclareComponent (with full template compilation). * * This plugin works in two phases: * 1. During dependency optimization (Rolldown pre-bundling) via a Rolldown load plugin @@ -28,13 +29,35 @@ const SKIP_REGEX = /[\\/]@angular[\\/](?:compiler|core)[\\/]/ // Match JS files in node_modules (Angular FESM bundles) const NODE_MODULES_JS_REGEX = /node_modules\/.*\.[cm]?js$/ +/** + * Run the OXC Rust linker on the given code. + */ +async function linkCode( + code: string, + id: string, +): Promise<{ code: string; map: string | null; linked: boolean }> { + const result = await linkAngularPackage(code, id) + return { + code: result.linked ? result.code : code, + map: result.map ?? null, + linked: result.linked, + } +} + export function angularLinkerPlugin(): Plugin { return { name: '@voidzero-dev/vite-plugin-angular-linker', - config() { + config(_, { command }) { return { optimizeDeps: { rolldownOptions: { + transform: { + define: { + ngJitMode: 'false', + ngI18nClosureMode: 'false', + ...(command === 'serve' ? {} : { ngDevMode: 'false' }), + }, + }, plugins: [ { name: 'angular-linker', @@ -55,7 +78,7 @@ export function angularLinkerPlugin(): Plugin { return } - const result = linkAngularPackageSync(code, id) + const result = await linkCode(code, id) if (!result.linked) { return @@ -75,13 +98,13 @@ export function angularLinkerPlugin(): Plugin { id: NODE_MODULES_JS_REGEX, code: LINKER_DECLARATION_PREFIX, }, - handler(code, id) { + async handler(code, id) { // Skip packages that don't need linking if (SKIP_REGEX.test(id)) { return } - const result = linkAngularPackageSync(code, id) + const result = await linkCode(code, id) if (!result.linked) { return @@ -89,7 +112,7 @@ export function angularLinkerPlugin(): Plugin { return { code: result.code, - map: result.map ?? null, + map: result.map, } }, }, diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index d43599385..d7782262d 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -301,6 +301,19 @@ export function angular(options: PluginOptions = {}): Plugin[] { const fileId = decodedComponentId.slice(0, atIndex) const resolvedId = resolve(process.cwd(), fileId) + // Only return HMR update module if the file has been invalidated (changed). + // On initial page load, modules haven't been invalidated yet, so we return + // an empty response. This prevents ɵɵreplaceMetadata from being called + // unnecessarily during initial load, which would re-create views and cause + // errors with @Required() decorators and directive matching. + const mod = server.moduleGraph.getModuleById(resolvedId) + if (!mod?.lastInvalidationTimestamp) { + res.setHeader('Content-Type', 'text/javascript') + res.setHeader('Cache-Control', 'no-cache') + res.end('') + return + } + try { const source = await readFile(resolvedId, 'utf-8') const { templateUrls, styleUrls } = await extractComponentUrls(source, resolvedId) @@ -385,14 +398,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { id: ANGULAR_TS_REGEX, }, async handler(code, id) { - // DEBUG: Log all files being considered - if (id.includes('nav-base') || id.includes('nav-item')) { - console.log('[OXC DEBUG] Handler called for:', id) - console.log(' - In node_modules:', id.includes('node_modules')) - console.log(' - Has @Directive:', code.includes('@Directive')) - console.log(' - Has @Component:', code.includes('@Component')) - } - // Skip node_modules if (id.includes('node_modules')) { return From d417dc97956c1e9a67daf7607ccb503ce580def8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 12 Feb 2026 23:33:40 +0800 Subject: [PATCH 02/11] fix(hmr): fix HMR update tracking and inputConfig handling - Use a dedicated `pendingHmrUpdates` Set to track which component files have pending HMR updates instead of trying to set Vite's read-only `lastInvalidationTimestamp` property on ModuleNode - Make `inputConfig` override conditional to avoid setting `inputs: undefined` for components without inputs, which would corrupt the component definition Co-Authored-By: Claude Opus 4.6 --- .../src/hmr/update_module.rs | 17 +++++++++---- napi/angular-compiler/vite-plugin/index.ts | 24 ++++++++++--------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/crates/oxc_angular_compiler/src/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index 6f8ed066d..c69cf77fa 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -152,9 +152,14 @@ fn generate_hmr_update_module_internal( // `parseAndConvertInputsForDefinition`). If we don't override, ɵɵdefineComponent // will process them again, producing corrupted input mappings. // `inputConfig` stores the original unprocessed inputs format. + // Only override when inputConfig exists (components with inputs); otherwise + // setting `inputs: undefined` would corrupt the component definition. output.push_str(&format!(" {}.ɵcmp = i0.ɵɵdefineComponent({{\n", class_name)); output.push_str(&format!(" ...{}.ɵcmp,\n", class_name)); - output.push_str(&format!(" inputs: {}.ɵcmp.inputConfig,\n", class_name)); + output.push_str(&format!( + " ...({cn}.ɵcmp.inputConfig ? {{ inputs: {cn}.ɵcmp.inputConfig }} : {{}}),\n", + cn = class_name + )); // Add template function if present if let Some(template_js) = template_js { @@ -271,13 +276,15 @@ mod tests { None, ); - // The HMR module must override `inputs` with `inputConfig` to avoid - // double-processing by `parseAndConvertInputsForDefinition`. - assert!(result.contains("inputs: AppComponent.ɵcmp.inputConfig")); + // The HMR module must conditionally override `inputs` with `inputConfig` + // to avoid double-processing by `parseAndConvertInputsForDefinition`. + // It must only do so when inputConfig exists to avoid setting inputs to undefined. + assert!(result.contains("AppComponent.ɵcmp.inputConfig")); + assert!(result.contains("inputs:")); // `inputs` override must come AFTER the spread to take precedence let spread_pos = result.find("...AppComponent.ɵcmp").unwrap(); - let inputs_pos = result.find("inputs: AppComponent.ɵcmp.inputConfig").unwrap(); + let inputs_pos = result.find("inputConfig").unwrap(); assert!(inputs_pos > spread_pos); } diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index d7782262d..223f38d72 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -111,6 +111,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Cache for resolved resources const resourceCache = new Map() + // Track component files with pending HMR updates (set by fs.watch, checked by HMR endpoint) + const pendingHmrUpdates = new Set() + /** * Resolve external template/style URLs and read their contents. */ @@ -227,10 +230,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (componentFile && componentIds.has(componentFile)) { debugHmr('resource change triggers HMR: %s -> %s', normalizedFile, componentFile) - // NOTE: We intentionally do NOT invalidate the component module here. - // The HMR URL includes a timestamp for cache-busting, so the dynamic import - // will fetch fresh content. Invalidating would trigger Vite's module - // propagation logic and cause an unwanted full page reload. + // Mark this component as having a pending HMR update so the + // HMR endpoint serves the update module instead of an empty response. + pendingHmrUpdates.add(componentFile) // Send HMR update event const componentId = `${componentFile}@${componentIds.get(componentFile)}` @@ -301,18 +303,18 @@ export function angular(options: PluginOptions = {}): Plugin[] { const fileId = decodedComponentId.slice(0, atIndex) const resolvedId = resolve(process.cwd(), fileId) - // Only return HMR update module if the file has been invalidated (changed). - // On initial page load, modules haven't been invalidated yet, so we return - // an empty response. This prevents ɵɵreplaceMetadata from being called - // unnecessarily during initial load, which would re-create views and cause - // errors with @Required() decorators and directive matching. - const mod = server.moduleGraph.getModuleById(resolvedId) - if (!mod?.lastInvalidationTimestamp) { + // Only return HMR update module if there's a pending update from our + // custom fs.watch handler. On initial page load, there are no pending + // updates, so we return an empty response. This prevents ɵɵreplaceMetadata + // from being called unnecessarily during initial load, which would + // re-create views and cause errors with @Required() decorators. + if (!pendingHmrUpdates.has(fileId)) { res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') res.end('') return } + pendingHmrUpdates.delete(fileId) try { const source = await readFile(resolvedId, 'utf-8') From 771aef666e0434f33141f76cd4d31098560261d1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 10:08:25 +0800 Subject: [PATCH 03/11] add hmr test --- .../e2e/app/src/app/app.component.ts | 3 + .../angular-compiler/e2e/app/src/app/app.html | 1 + .../e2e/app/src/app/card.component.ts | 11 ++ .../angular-compiler/e2e/app/src/app/card.css | 14 +++ .../e2e/app/src/app/card.html | 4 + .../e2e/tests/hmr-css.spec.ts | 5 +- .../e2e/tests/hmr-input-component.spec.ts | 113 ++++++++++++++++++ napi/angular-compiler/tsconfig.node.json | 3 +- 8 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 napi/angular-compiler/e2e/app/src/app/card.component.ts create mode 100644 napi/angular-compiler/e2e/app/src/app/card.css create mode 100644 napi/angular-compiler/e2e/app/src/app/card.html create mode 100644 napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts diff --git a/napi/angular-compiler/e2e/app/src/app/app.component.ts b/napi/angular-compiler/e2e/app/src/app/app.component.ts index 8b4f14f0d..491979aa8 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.component.ts +++ b/napi/angular-compiler/e2e/app/src/app/app.component.ts @@ -1,9 +1,12 @@ import { Component, signal } from '@angular/core' +import { Card } from './card.component' + @Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', + imports: [Card], }) export class App { protected readonly title = signal('E2E_TITLE') diff --git a/napi/angular-compiler/e2e/app/src/app/app.html b/napi/angular-compiler/e2e/app/src/app/app.html index 86bf3af20..f01634f6c 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.html +++ b/napi/angular-compiler/e2e/app/src/app/app.html @@ -1,4 +1,5 @@

{{ title() }}

E2E test fixture for HMR testing.

+
diff --git a/napi/angular-compiler/e2e/app/src/app/card.component.ts b/napi/angular-compiler/e2e/app/src/app/card.component.ts new file mode 100644 index 000000000..b493ff3f1 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/card.component.ts @@ -0,0 +1,11 @@ +import { Component, input } from '@angular/core' + +@Component({ + selector: 'app-card', + templateUrl: './card.html', + styleUrl: './card.css', +}) +export class Card { + cardTitle = input.required() + cardValue = input(0) +} diff --git a/napi/angular-compiler/e2e/app/src/app/card.css b/napi/angular-compiler/e2e/app/src/app/card.css new file mode 100644 index 000000000..c3aa1e721 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/card.css @@ -0,0 +1,14 @@ +.card { + border: 1px solid #ccc; + padding: 10px; + margin: 10px 0; +} + +.card-title { + color: green; + margin: 0; +} + +.card-value { + font-weight: bold; +} diff --git a/napi/angular-compiler/e2e/app/src/app/card.html b/napi/angular-compiler/e2e/app/src/app/card.html new file mode 100644 index 000000000..75d4b86f3 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/card.html @@ -0,0 +1,4 @@ +
+

{{ cardTitle() }}

+ {{ cardValue() }} +
diff --git a/napi/angular-compiler/e2e/tests/hmr-css.spec.ts b/napi/angular-compiler/e2e/tests/hmr-css.spec.ts index 0cdd963ba..314522626 100644 --- a/napi/angular-compiler/e2e/tests/hmr-css.spec.ts +++ b/napi/angular-compiler/e2e/tests/hmr-css.spec.ts @@ -119,7 +119,10 @@ test.describe('CSS Style HMR', () => { ) }) - // Also modify HTML to use the new class + // Wait for CSS HMR to settle before modifying HTML + await waitForHmr() + + // Now modify HTML to use the new class await fileModifier.modifyFile('app.html', (content) => { return content.replace('

', '

') }) diff --git a/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts b/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts new file mode 100644 index 000000000..ade518ad1 --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '../fixtures/test-fixture.js' + +test.describe('Input Component HMR', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('component with inputs renders correctly on initial load', async ({ page }) => { + // Verify the Card component renders with bound input values + await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') + await expect(page.locator('.card-value')).toContainText('42') + }) + + test('CSS HMR on component with inputs preserves input bindings', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + // Verify initial state + await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') + await expect(page.locator('.card-value')).toContainText('42') + + const sentinelId = await hmrDetector.addSentinel() + + // Change card styles - modify title color from green to red + await fileModifier.modifyFile('card.css', (content) => { + return content.replace('color: green', 'color: rgb(255, 0, 0)') + }) + + await waitForHmr() + + // Verify no full reload + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + + // Verify style changed + const titleColor = await page.locator('.card-title').evaluate((el) => { + return getComputedStyle(el).color + }) + expect(titleColor).toBe('rgb(255, 0, 0)') + + // Verify input bindings still work after HMR update + // This exercises the inputConfig conditional in update_module.rs: + // If inputs were corrupted, these values would be missing or wrong + await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') + await expect(page.locator('.card-value')).toContainText('42') + }) + + test('template HMR on component with inputs preserves input bindings', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + // Verify initial state + await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') + + const sentinelId = await hmrDetector.addSentinel() + + // Modify the card template - add a prefix to the title, keeping the binding + await fileModifier.modifyFile('card.html', (content) => { + return content.replace('{{ cardTitle() }}', 'UPDATED: {{ cardTitle() }}') + }) + + await waitForHmr() + + // Verify no full reload + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + + // Verify template changed AND input binding still works + await expect(page.locator('.card-title')).toContainText('UPDATED: INPUT_TITLE') + await expect(page.locator('.card-value')).toContainText('42') + }) + + test('multiple HMR updates on component with inputs do not corrupt inputs', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + + // First CSS change + await fileModifier.modifyFile('card.css', (content) => { + return content.replace('color: green', 'color: rgb(255, 0, 0)') + }) + await waitForHmr() + + // Verify inputs survive first update + await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') + await expect(page.locator('.card-value')).toContainText('42') + + // Second CSS change + await fileModifier.modifyFile('card.css', (content) => { + return content.replace('color: rgb(255, 0, 0)', 'color: rgb(0, 0, 255)') + }) + await waitForHmr() + + // Verify inputs survive second update + await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') + await expect(page.locator('.card-value')).toContainText('42') + + // Verify style reflects latest change + const titleColor = await page.locator('.card-title').evaluate((el) => { + return getComputedStyle(el).color + }) + expect(titleColor).toBe('rgb(0, 0, 255)') + + // No reload through all updates + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) +}) diff --git a/napi/angular-compiler/tsconfig.node.json b/napi/angular-compiler/tsconfig.node.json index db4abe667..dac010d24 100644 --- a/napi/angular-compiler/tsconfig.node.json +++ b/napi/angular-compiler/tsconfig.node.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "rootDir": "." }, "include": ["./e2e/playwright.config.ts", "./e2e/fixtures/test-fixture.ts"] } From 3b7945c01de0b99d6268db2e66b40948f27d63b0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 10:51:45 +0800 Subject: [PATCH 04/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 3b2025cea..43ea0dac0 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -969,8 +969,7 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source // Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc. Expression::ObjectExpression(obj) => { let public_name = get_string_property(obj, "publicName").unwrap_or(&key); - let declared_name = - get_string_property(obj, "classPropertyName").unwrap_or(public_name); + let declared_name = get_string_property(obj, "classPropertyName").unwrap_or(&key); let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false); let is_required = get_bool_property(obj, "isRequired").unwrap_or(false); let transform = get_property_source(obj, "transformFunction", source); @@ -1196,13 +1195,14 @@ fn build_queries( let predicate = get_property_source(query_obj.as_ref(), "predicate", source).unwrap_or("null"); - // Calculate flags + // Calculate flags: DESCENDANTS=1, IS_STATIC=2, EMIT_DISTINCT_CHANGES_ONLY=4 + // View queries always have descendants=true; content queries read it from metadata. let flags = if is_content_query { if descendants { 5u32 } else { 4u32 } } else if is_static { - 7u32 + 7u32 // DESCENDANTS | IS_STATIC | EMIT_DISTINCT_CHANGES_ONLY } else { - 4u32 + 5u32 // DESCENDANTS | EMIT_DISTINCT_CHANGES_ONLY }; // Signal queries use different flags From e0d7caa718706c083992a2ca340e32e3f2e6d987 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 11:24:25 +0800 Subject: [PATCH 05/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 43ea0dac0..42c48f9fb 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1197,24 +1197,36 @@ fn build_queries( // Calculate flags: DESCENDANTS=1, IS_STATIC=2, EMIT_DISTINCT_CHANGES_ONLY=4 // View queries always have descendants=true; content queries read it from metadata. - let flags = if is_content_query { - if descendants { 5u32 } else { 4u32 } - } else if is_static { - 7u32 // DESCENDANTS | IS_STATIC | EMIT_DISTINCT_CHANGES_ONLY - } else { - 5u32 // DESCENDANTS | EMIT_DISTINCT_CHANGES_ONLY - }; - - // Signal queries use different flags - let flags = if is_signal { flags | 1 } else { flags }; + let has_descendants = if is_content_query { descendants } else { true }; + let mut flags = 4u32; // EMIT_DISTINCT_CHANGES_ONLY (always on) + if has_descendants { + flags |= 1; // DESCENDANTS + } + if is_static { + flags |= 2; // IS_STATIC + } - // Create block + // Create block — signal queries use different instructions with ctx.propertyName if is_content_query { - let mut args = format!("dirIndex, {predicate}, {flags}"); + if is_signal { + let mut args = format!("dirIndex, ctx.{prop_name}, {predicate}, {flags}"); + if let Some(read_expr) = read { + args = format!("{args}, {read_expr}"); + } + create_stmts.push(format!("{ns}.\u{0275}\u{0275}contentQuerySignal({args})")); + } else { + let mut args = format!("dirIndex, {predicate}, {flags}"); + if let Some(read_expr) = read { + args = format!("{args}, {read_expr}"); + } + create_stmts.push(format!("{ns}.\u{0275}\u{0275}contentQuery({args})")); + } + } else if is_signal { + let mut args = format!("ctx.{prop_name}, {predicate}, {flags}"); if let Some(read_expr) = read { args = format!("{args}, {read_expr}"); } - create_stmts.push(format!("{ns}.\u{0275}\u{0275}contentQuery({args})")); + create_stmts.push(format!("{ns}.\u{0275}\u{0275}viewQuerySignal({args})")); } else { let mut args = format!("{predicate}, {flags}"); if let Some(read_expr) = read { @@ -1223,17 +1235,21 @@ fn build_queries( create_stmts.push(format!("{ns}.\u{0275}\u{0275}viewQuery({args})")); } - // Update block — declare `_t` once before the first query refresh - let t_var = if !t_declared { - t_declared = true; - "let _t;\n" + // Update block — signal queries just advance; regular queries refresh+assign + if is_signal { + update_stmts.push(format!("{ns}.\u{0275}\u{0275}queryAdvance()")); } else { - "" - }; - let access = if first { ".first" } else { "" }; - update_stmts.push(format!( - "{t_var}{ns}.\u{0275}\u{0275}queryRefresh(_t = {ns}.\u{0275}\u{0275}loadQuery()) && (ctx.{prop_name} = _t{access})" - )); + let t_var = if !t_declared { + t_declared = true; + "let _t;\n" + } else { + "" + }; + let access = if first { ".first" } else { "" }; + update_stmts.push(format!( + "{t_var}{ns}.\u{0275}\u{0275}queryRefresh(_t = {ns}.\u{0275}\u{0275}loadQuery()) && (ctx.{prop_name} = _t{access})" + )); + } } let create_block = create_stmts.join(";\n"); From e7079616c62246ade4118791af2b7c4479d4371f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 11:50:23 +0800 Subject: [PATCH 06/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 42c48f9fb..86a441e29 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1272,7 +1272,7 @@ fn build_queries( /// - `usesInheritance: true` → `ns.ɵɵInheritDefinitionFeature` /// - `usesOnChanges: true` → `ns.ɵɵNgOnChangesFeature` /// - `providers: [...]` → `ns.ɵɵProvidersFeature([...])` -/// - `viewProviders: [...]` → `ns.ɵɵViewProvidersFeature([...])` +/// - `providers` + `viewProviders` → `ns.ɵɵProvidersFeature(providers, viewProviders?)` fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option { let mut features: Vec = Vec::new(); @@ -1282,11 +1282,19 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option if get_bool_property(meta, "usesOnChanges") == Some(true) { features.push(format!("{ns}.\u{0275}\u{0275}NgOnChangesFeature")); } - if let Some(providers) = get_property_source(meta, "providers", source) { - features.push(format!("{ns}.\u{0275}\u{0275}ProvidersFeature({providers})")); - } - if let Some(view_providers) = get_property_source(meta, "viewProviders", source) { - features.push(format!("{ns}.\u{0275}\u{0275}ViewProvidersFeature({view_providers})")); + let providers = get_property_source(meta, "providers", source); + let view_providers = get_property_source(meta, "viewProviders", source); + match (providers, view_providers) { + (Some(p), Some(vp)) => { + features.push(format!("{ns}.\u{0275}\u{0275}ProvidersFeature({p}, {vp})")); + } + (Some(p), None) => { + features.push(format!("{ns}.\u{0275}\u{0275}ProvidersFeature({p})")); + } + (None, Some(vp)) => { + features.push(format!("{ns}.\u{0275}\u{0275}ProvidersFeature([], {vp})")); + } + (None, None) => {} } if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) } From dac3753c49abf6444b90e0f5280609292c8560bf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 12:44:01 +0800 Subject: [PATCH 07/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 86a441e29..8903046c0 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1408,6 +1408,11 @@ fn link_component( let standalone = get_bool_property(meta, "isStandalone").unwrap_or(true); parts.push(format!("standalone: {standalone}")); + // 11b. hostDirectives (Directive Composition API) + if let Some(host_directives) = get_property_source(meta, "hostDirectives", source) { + parts.push(format!("hostDirectives: {host_directives}")); + } + // 12. features if let Some(features) = build_features(meta, source, ns) { parts.push(format!("features: {features}")); From 61de6a350acc0a2737826b2eee26412bd2ad321a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 13:05:10 +0800 Subject: [PATCH 08/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 8903046c0..6faf059e6 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1444,21 +1444,62 @@ fn link_component( } } - // 19. styles - if let Some(styles) = get_property_source(meta, "styles", source) { - parts.push(format!("styles: {styles}")); + // 19-20. styles + encapsulation (interdependent) + // Determine encapsulation mode: Emulated is the default + let is_emulated = match get_property_source(meta, "encapsulation", source) { + Some(encap) if encap.contains("None") => false, + Some(encap) if encap.contains("ShadowDom") => false, + _ => true, // Emulated is the default + }; + let is_shadow_dom = matches!( + get_property_source(meta, "encapsulation", source), + Some(encap) if encap.contains("ShadowDom") + ); + + // Process styles: apply CSS scoping for Emulated encapsulation + let mut has_styles = false; + if let Some(styles_arr) = get_array_property(meta, "styles") { + let mut scoped_styles: Vec = Vec::new(); + for el in &styles_arr.elements { + let expr = match el { + ArrayExpressionElement::SpreadElement(_) => continue, + _ => el.to_expression(), + }; + if let Expression::StringLiteral(s) = expr { + let style = s.value.as_str(); + if is_emulated { + let scoped = + crate::styles::shim_css_text(style, "_ngcontent-%COMP%", "_nghost-%COMP%"); + if !scoped.trim().is_empty() { + scoped_styles.push(format!( + "\"{}\"", + scoped.replace('\\', "\\\\").replace('"', "\\\"") + )); + } + } else if !style.trim().is_empty() { + scoped_styles + .push(format!("\"{}\"", style.replace('\\', "\\\\").replace('"', "\\\""))); + } + } + } + if !scoped_styles.is_empty() { + has_styles = true; + parts.push(format!("styles: [{}]", scoped_styles.join(", "))); + } } - // 20. encapsulation - if let Some(encap) = get_property_source(meta, "encapsulation", source) { - // Convert ViewEncapsulation enum to numeric value - if encap.contains("None") { - parts.push("encapsulation: 2".to_string()); - } else if encap.contains("ShadowDom") { - parts.push("encapsulation: 3".to_string()); - } - // Emulated (0) is the default, no need to emit + // Encapsulation: downgrade Emulated → None when no styles + // (per Angular compiler.ts: "If there is no style, don't generate css selectors on elements") + if is_shadow_dom { + parts.push("encapsulation: 3".to_string()); + } else if !is_emulated { + // Explicitly set to None + parts.push("encapsulation: 2".to_string()); + } else if !has_styles { + // Emulated with no styles → downgrade to None + parts.push("encapsulation: 2".to_string()); } + // else: Emulated with styles is the default (0), no need to emit // 21. data (animations) if let Some(animations) = get_property_source(meta, "animations", source) { From ee65d9c1d1739134ec3a22e241a24535b16c6147 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 13:22:45 +0800 Subject: [PATCH 09/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 8 ++------ crates/oxc_angular_compiler/src/output/emitter.rs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 6faf059e6..d9516a806 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1471,14 +1471,10 @@ fn link_component( let scoped = crate::styles::shim_css_text(style, "_ngcontent-%COMP%", "_nghost-%COMP%"); if !scoped.trim().is_empty() { - scoped_styles.push(format!( - "\"{}\"", - scoped.replace('\\', "\\\\").replace('"', "\\\"") - )); + scoped_styles.push(crate::output::emitter::escape_string(&scoped, false)); } } else if !style.trim().is_empty() { - scoped_styles - .push(format!("\"{}\"", style.replace('\\', "\\\\").replace('"', "\\\""))); + scoped_styles.push(crate::output::emitter::escape_string(style, false)); } } } diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index 4b284de01..7e06755dd 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -1232,7 +1232,7 @@ fn is_nullish_coalesce(expr: &OutputExpression<'_>) -> bool { /// Characters above the BMP (U+10000+) are encoded as UTF-16 surrogate pairs /// (`\uXXXX\uXXXX`). This matches TypeScript's emitter behavior, which escapes /// non-ASCII characters in string literals. -fn escape_string(input: &str, escape_dollar: bool) -> String { +pub(crate) fn escape_string(input: &str, escape_dollar: bool) -> String { let mut result = String::with_capacity(input.len() + 2); result.push('"'); for c in input.chars() { From 0ae37c926f8b40cf397ba887e2056f28b30c1718 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 13:55:40 +0800 Subject: [PATCH 10/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index d9516a806..a5542e29a 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1272,16 +1272,12 @@ fn build_queries( /// - `usesInheritance: true` → `ns.ɵɵInheritDefinitionFeature` /// - `usesOnChanges: true` → `ns.ɵɵNgOnChangesFeature` /// - `providers: [...]` → `ns.ɵɵProvidersFeature([...])` -/// - `providers` + `viewProviders` → `ns.ɵɵProvidersFeature(providers, viewProviders?)` +/// Order is important: ProvidersFeature → InheritDefinitionFeature → NgOnChangesFeature +/// (see definition.rs line 990 and packages/compiler/src/render3/view/compiler.ts:119-161) fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option { let mut features: Vec = Vec::new(); - if get_bool_property(meta, "usesInheritance") == Some(true) { - features.push(format!("{ns}.\u{0275}\u{0275}InheritDefinitionFeature")); - } - if get_bool_property(meta, "usesOnChanges") == Some(true) { - features.push(format!("{ns}.\u{0275}\u{0275}NgOnChangesFeature")); - } + // 1. ProvidersFeature — must come before InheritDefinitionFeature let providers = get_property_source(meta, "providers", source); let view_providers = get_property_source(meta, "viewProviders", source); match (providers, view_providers) { @@ -1297,6 +1293,16 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option (None, None) => {} } + // 2. InheritDefinitionFeature + if get_bool_property(meta, "usesInheritance") == Some(true) { + features.push(format!("{ns}.\u{0275}\u{0275}InheritDefinitionFeature")); + } + + // 3. NgOnChangesFeature + if get_bool_property(meta, "usesOnChanges") == Some(true) { + features.push(format!("{ns}.\u{0275}\u{0275}NgOnChangesFeature")); + } + if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) } } @@ -1875,4 +1881,32 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: " result.code ); } + + #[test] + fn test_features_order_providers_before_inherit() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyComp { +} +MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyComp, selector: "my-comp", providers: [SomeProvider], usesInheritance: true, usesOnChanges: true, template: "
" }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + let code = &result.code; + // Canonical order: ProvidersFeature → InheritDefinitionFeature → NgOnChangesFeature + let providers_pos = code.find("ProvidersFeature").expect("should have ProvidersFeature"); + let inherit_pos = + code.find("InheritDefinitionFeature").expect("should have InheritDefinitionFeature"); + let on_changes_pos = + code.find("NgOnChangesFeature").expect("should have NgOnChangesFeature"); + assert!( + providers_pos < inherit_pos, + "ProvidersFeature must come before InheritDefinitionFeature" + ); + assert!( + inherit_pos < on_changes_pos, + "InheritDefinitionFeature must come before NgOnChangesFeature" + ); + } } From 4a8d4bc6202fdb5d70882b8b46a2e793cc675269 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Feb 2026 14:30:36 +0800 Subject: [PATCH 11/11] fix review comments --- crates/oxc_angular_compiler/src/linker/mod.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index a5542e29a..ce6c91d7b 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -972,7 +972,10 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source let declared_name = get_string_property(obj, "classPropertyName").unwrap_or(&key); let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false); let is_required = get_bool_property(obj, "isRequired").unwrap_or(false); - let transform = get_property_source(obj, "transformFunction", source); + // Angular emits `transformFunction: null` for signal inputs without + // transforms. Filter out "null" to avoid setting HasDecoratorInputTransform. + let transform = + get_property_source(obj, "transformFunction", source).filter(|v| *v != "null"); let mut flags = 0u32; if is_signal { @@ -1909,4 +1912,26 @@ MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0. "InheritDefinitionFeature must come before NgOnChangesFeature" ); } + + #[test] + fn test_signal_input_null_transform_no_flag() { + let allocator = Allocator::default(); + // Angular emits `transformFunction: null` for signal inputs without transforms. + // This must NOT set the HasDecoratorInputTransform flag (2). + let code = r#" +import * as i0 from "@angular/core"; +class MyComp { +} +MyComp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.0", ngImport: i0, type: MyComp, selector: "my-comp", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null } }, template: "
" }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + // Signal input flag = 1, NOT 3 (1 | 2). Must not include HasDecoratorInputTransform. + assert!( + result.code.contains(r#"name: [1, "name", "name"]"#), + "Signal input with null transform should have flags=1 (SignalBased only), got:\n{}", + result.code + ); + assert!(!result.code.contains("null]"), "Should not include null transform in output"); + } }