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..c69cf77fa 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -146,9 +146,20 @@ 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. + // 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!( + " ...({cn}.ɵcmp.inputConfig ? {{ inputs: {cn}.ɵcmp.inputConfig }} : {{}}),\n", + cn = class_name + )); // Add template function if present if let Some(template_js) = template_js { @@ -255,6 +266,28 @@ 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 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("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..ce6c91d7b 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,113 @@ 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(&key); + let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false); + let is_required = get_bool_property(obj, "isRequired").unwrap_or(false); + // 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 { + 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 +1023,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 +1054,482 @@ 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: DESCENDANTS=1, IS_STATIC=2, EMIT_DISTINCT_CHANGES_ONLY=4 + // View queries always have descendants=true; content queries read it from metadata. + 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 — signal queries use different instructions with ctx.propertyName + if is_content_query { + 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}viewQuerySignal({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 — signal queries just advance; regular queries refresh+assign + if is_signal { + update_stmts.push(format!("{ns}.\u{0275}\u{0275}queryAdvance()")); + } else { + 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([...])` +/// 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(); + + // 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) { + (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) => {} + } + + // 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(", "))) } +} + +/// 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}")); + + // 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}")); + } + + // 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-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(crate::output::emitter::escape_string(&scoped, false)); + } + } else if !style.trim().is_empty() { + scoped_styles.push(crate::output::emitter::escape_string(style, false)); + } + } + } + if !scoped_styles.is_empty() { + has_styles = true; + parts.push(format!("styles: [{}]", scoped_styles.join(", "))); + } + } + + // 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) { + 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 +1634,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 +1672,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 +1706,232 @@ 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 + ); + } + + #[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" + ); + } + + #[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"); } } 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() { 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"] } 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..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,6 +303,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 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') const { templateUrls, styleUrls } = await extractComponentUrls(source, resolvedId) @@ -385,14 +400,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