diff --git a/crates/oxc_angular_compiler/src/ast/r3.rs b/crates/oxc_angular_compiler/src/ast/r3.rs index 845281c52..9e4f8ea4e 100644 --- a/crates/oxc_angular_compiler/src/ast/r3.rs +++ b/crates/oxc_angular_compiler/src/ast/r3.rs @@ -1084,8 +1084,8 @@ pub struct R3HoverDeferredTrigger<'a> { /// A timer deferred trigger. #[derive(Debug)] pub struct R3TimerDeferredTrigger { - /// Delay in milliseconds. - pub delay: u32, + /// Delay in milliseconds (f64 to preserve fractional precision). + pub delay: f64, /// Source span. pub source_span: Span, /// Name span. diff --git a/crates/oxc_angular_compiler/src/ir/ops.rs b/crates/oxc_angular_compiler/src/ir/ops.rs index 3d1712aa2..a672cb6e5 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -1062,7 +1062,7 @@ pub struct DeferOnOp<'a> { /// Target name for local ref targeting. pub target_name: Option>, /// Timer delay. - pub delay: Option, + pub delay: Option, /// Viewport options (for viewport trigger). pub options: Option>>, } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/defer.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/defer.rs index 9ef1a032f..298973d90 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/defer.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/defer.rs @@ -200,7 +200,7 @@ pub fn create_defer_on_stmt<'a>( target_slot: Option, target_slot_view_steps: Option, modifier: DeferOpModifierKind, - delay: Option, + delay: Option, options: Option>, ) -> OutputStatement<'a> { let mut args = OxcVec::new_in(allocator); @@ -214,7 +214,7 @@ pub fn create_defer_on_stmt<'a>( // Timer trigger takes the delay as first argument if let Some(d) = delay { args.push(OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::Number(d as f64), source_span: None }, + LiteralExpr { value: LiteralValue::Number(d), source_span: None }, allocator, ))); } diff --git a/crates/oxc_angular_compiler/src/transform/control_flow.rs b/crates/oxc_angular_compiler/src/transform/control_flow.rs index ffe522248..d4c66f48d 100644 --- a/crates/oxc_angular_compiler/src/transform/control_flow.rs +++ b/crates/oxc_angular_compiler/src/transform/control_flow.rs @@ -289,6 +289,9 @@ pub struct ForLoopParams<'a> { pub context_variables: Vec<'a, R3Variable<'a>>, /// Parse errors. pub errors: std::vec::Vec, + /// Whether the core expression failed to parse (no parameters, unclosed parens, or missing "of"). + /// When true, secondary validations like missing track should be skipped. + pub expression_parse_failed: bool, } /// Track expression info. @@ -318,6 +321,7 @@ pub fn parse_for_loop_parameters<'a>( track_by: None, context_variables: create_default_context_variables(allocator, block_start_span), errors, + expression_parse_failed: true, }; } @@ -334,6 +338,7 @@ pub fn parse_for_loop_parameters<'a>( track_by: None, context_variables: create_default_context_variables(allocator, block_start_span), errors, + expression_parse_failed: true, }; }; @@ -348,6 +353,7 @@ pub fn parse_for_loop_parameters<'a>( track_by: None, context_variables: create_default_context_variables(allocator, block_start_span), errors, + expression_parse_failed: true, }; }; @@ -460,7 +466,14 @@ pub fn parse_for_loop_parameters<'a>( errors.push(format!("Unrecognized @for loop parameter \"{}\"", param_str)); } - ForLoopParams { item, expression, track_by, context_variables, errors } + ForLoopParams { + item, + expression, + track_by, + context_variables, + errors, + expression_parse_failed: false, + } } /// Parses the `let` parameter of a @for loop. @@ -490,7 +503,9 @@ fn parse_let_parameter<'a>( continue; } - let parts: std::vec::Vec<&str> = trimmed.splitn(2, '=').collect(); + // Use full split (not splitn) to detect malformed patterns like "a=b=c" + // which has 3 segments. Angular checks expressionParts.length === 2. + let parts: std::vec::Vec<&str> = trimmed.split('=').collect(); if parts.len() != 2 { errors.push( "Invalid @for loop \"let\" parameter. Parameter should match the pattern \" = \"".to_string() @@ -700,7 +715,7 @@ fn strip_optional_parentheses(expr: &str, errors: &mut std::vec::Vec) -> Some(inner.trim().to_string()) } -/// Checks if an expression contains a pipe. +/// Checks if an expression contains a pipe (full recursive traversal matching Angular's visitor). fn contains_pipe(expr: &AngularExpression<'_>) -> bool { match expr { AngularExpression::BindingPipe(_) => true, @@ -715,10 +730,31 @@ fn contains_pipe(expr: &AngularExpression<'_>) -> bool { AngularExpression::Call(f) => { contains_pipe(&f.receiver) || f.args.iter().any(|a| contains_pipe(a)) } + AngularExpression::SafeCall(f) => { + contains_pipe(&f.receiver) || f.args.iter().any(|a| contains_pipe(a)) + } AngularExpression::PrefixNot(p) => contains_pipe(&p.expression), AngularExpression::Unary(u) => contains_pipe(&u.expr), AngularExpression::TypeofExpression(t) => contains_pipe(&t.expression), - _ => false, + AngularExpression::LiteralArray(a) => a.expressions.iter().any(|e| contains_pipe(e)), + AngularExpression::LiteralMap(m) => m.values.iter().any(|v| contains_pipe(v)), + AngularExpression::Chain(c) => c.expressions.iter().any(|e| contains_pipe(e)), + AngularExpression::Interpolation(i) => i.expressions.iter().any(|e| contains_pipe(e)), + AngularExpression::VoidExpression(v) => contains_pipe(&v.expression), + AngularExpression::NonNullAssert(n) => contains_pipe(&n.expression), + AngularExpression::ParenthesizedExpression(p) => contains_pipe(&p.expression), + AngularExpression::SpreadElement(s) => contains_pipe(&s.expression), + AngularExpression::ArrowFunction(f) => contains_pipe(&f.body), + AngularExpression::TaggedTemplateLiteral(t) => { + contains_pipe(&t.tag) || t.template.expressions.iter().any(|e| contains_pipe(e)) + } + AngularExpression::TemplateLiteral(t) => t.expressions.iter().any(|e| contains_pipe(e)), + // Leaf nodes that cannot contain pipes + AngularExpression::Empty(_) + | AngularExpression::ImplicitReceiver(_) + | AngularExpression::ThisReceiver(_) + | AngularExpression::LiteralPrimitive(_) + | AngularExpression::RegularExpressionLiteral(_) => false, } } @@ -1329,6 +1365,16 @@ fn parse_single_on_trigger<'a>( errors.push("Hydration trigger \"hover\" cannot have parameters".to_string()); return; } + // Validate zero or one parameter (matching Angular's validatePlainReferenceBasedTrigger) + if let Some(p) = params { + let param_parts: std::vec::Vec<&str> = + p.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + if param_parts.len() > 1 { + errors + .push("\"hover\" trigger can only have zero or one parameters".to_string()); + return; + } + } let reference = params.map(|s| Atom::from(s.trim())); triggers.hover = Some(R3HoverDeferredTrigger { reference, @@ -1350,6 +1396,17 @@ fn parse_single_on_trigger<'a>( errors.push("Hydration trigger \"interaction\" cannot have parameters".to_string()); return; } + // Validate zero or one parameter (matching Angular's validatePlainReferenceBasedTrigger) + if let Some(p) = params { + let param_parts: std::vec::Vec<&str> = + p.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + if param_parts.len() > 1 { + errors.push( + "\"interaction\" trigger can only have zero or one parameters".to_string(), + ); + return; + } + } let reference = params.map(|s| Atom::from(s.trim())); triggers.interaction = Some(R3InteractionDeferredTrigger { reference, @@ -1403,28 +1460,9 @@ fn parse_single_on_trigger<'a>( }); } - "never" => { - // "never" is only valid as "hydrate never", not as "on never" - // Reference: r3_deferred_blocks.ts - HYDRATE_NEVER_PATTERN only matches "hydrate never" - // The OnTriggerParser switch statement has no case for NEVER, so "on never" falls to default - if hydrate_span.is_none() { - errors.push(format!("Unrecognized trigger type \"{}\"", name)); - return; - } - if triggers.never.is_some() { - errors.push("Duplicate 'never' trigger is not allowed".to_string()); - return; - } - triggers.never = Some(R3NeverDeferredTrigger { - source_span, - name_span: Some(trigger_span), - prefetch_span, - when_or_on_source_span: Some(trigger_span), - hydrate_span, - }); - } - _ => { + // "never" is only valid as "hydrate never" (top-level pattern), not as an on-trigger. + // Angular's OnTriggerParser switch has no case for NEVER, so it falls to default. errors.push(format!("Unrecognized trigger type \"{}\"", name)); } } @@ -1536,20 +1574,17 @@ fn extract_viewport_trigger_and_options<'a>( } } - // If we have remaining keys, create a new LiteralMap for options - let options = if !filtered_keys.is_empty() { - Some(AngularExpression::LiteralMap(oxc_allocator::Box::new_in( - LiteralMap { - span: map_span, - source_span: map_source_span, - keys: filtered_keys, - values: filtered_values, - }, - allocator, - ))) - } else { - None - }; + // Always create a LiteralMap for options, even if empty (matching Angular behavior). + // Angular keeps an empty LiteralMap {} when only the trigger key was present. + let options = Some(AngularExpression::LiteralMap(oxc_allocator::Box::new_in( + LiteralMap { + span: map_span, + source_span: map_source_span, + keys: filtered_keys, + values: filtered_values, + }, + allocator, + ))); ViewportTriggerResult { reference: trigger_ref, options, errors } } else { @@ -1562,7 +1597,8 @@ fn extract_viewport_trigger_and_options<'a>( } /// Parses a time value like "500ms" or "1.5s" to milliseconds. -fn parse_deferred_time(value: &str) -> Option { +/// Returns f64 to preserve fractional precision (matching Angular's parseFloat behavior). +fn parse_deferred_time(value: &str) -> Option { let value = value.trim(); if !is_valid_time_pattern(value) { @@ -1581,7 +1617,7 @@ fn parse_deferred_time(value: &str) -> Option { let num: f64 = num_str.parse().ok()?; let millis = if unit == "s" { num * 1000.0 } else { num }; - Some(millis as u32) + Some(millis) } #[cfg(test)] @@ -1644,20 +1680,23 @@ mod tests { #[test] fn test_parse_deferred_time() { // Milliseconds - assert_eq!(parse_deferred_time("500ms"), Some(500)); - assert_eq!(parse_deferred_time("100ms"), Some(100)); - assert_eq!(parse_deferred_time("0ms"), Some(0)); + assert_eq!(parse_deferred_time("500ms"), Some(500.0)); + assert_eq!(parse_deferred_time("100ms"), Some(100.0)); + assert_eq!(parse_deferred_time("0ms"), Some(0.0)); + + // Fractional milliseconds (must preserve precision) + assert_eq!(parse_deferred_time("1.5ms"), Some(1.5)); // Seconds - assert_eq!(parse_deferred_time("1s"), Some(1000)); - assert_eq!(parse_deferred_time("2s"), Some(2000)); - assert_eq!(parse_deferred_time("1.5s"), Some(1500)); + assert_eq!(parse_deferred_time("1s"), Some(1000.0)); + assert_eq!(parse_deferred_time("2s"), Some(2000.0)); + assert_eq!(parse_deferred_time("1.5s"), Some(1500.0)); // No unit defaults to ms - assert_eq!(parse_deferred_time("500"), Some(500)); + assert_eq!(parse_deferred_time("500"), Some(500.0)); // With whitespace - assert_eq!(parse_deferred_time(" 500ms "), Some(500)); + assert_eq!(parse_deferred_time(" 500ms "), Some(500.0)); // Invalid assert_eq!(parse_deferred_time("abc"), None); diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index aee7c9333..4700a0719 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -2202,6 +2202,9 @@ impl<'a> HtmlToR3Transform<'a> { block.start_span, ); + // Track whether the core expression failed to parse (to skip track validation). + let expression_parse_failed = params.expression_parse_failed; + // Add any parse errors for error in params.errors { self.errors.push(crate::util::ParseError { @@ -2234,12 +2237,16 @@ impl<'a> HtmlToR3Transform<'a> { let expression = params.expression; let context_variables = params.context_variables; - // Get track expression or create empty one for error recovery + // Get track expression or create empty one for error recovery. + // Only report missing-track error if the expression itself parsed successfully + // (matching Angular which returns null params and skips track validation on parse failure). let (track_by, track_keyword_span) = if let Some(track_info) = params.track_by { (track_info.expression, track_info.keyword_span) } else { - // Track is required but missing - report error and create empty for error recovery - self.report_error("@for loop must have a \"track\" expression", block.start_span); + if !expression_parse_failed { + // Track is required but missing - report error and create empty for error recovery + self.report_error("@for loop must have a \"track\" expression", block.start_span); + } let empty_ast = ASTWithSource { ast: self.create_empty_expression(block.span), source: None, diff --git a/napi/angular-compiler/e2e/compare/fixtures/defer/defer-trigger-edge-cases.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/defer/defer-trigger-edge-cases.fixture.ts new file mode 100644 index 000000000..245e69f15 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/defer/defer-trigger-edge-cases.fixture.ts @@ -0,0 +1,74 @@ +/** + * @defer trigger edge cases for compiler divergence testing. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'defer-viewport-trigger-only', + category: 'defer', + description: '@defer viewport with trigger option only (should keep empty options object)', + className: 'DeferViewportTriggerOnlyComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-defer-viewport-trigger-only', + standalone: true, + template: \` +
Trigger element
+ @defer (on viewport({trigger: myRef})) { +
Deferred
+ } + \`, +}) +export class DeferViewportTriggerOnlyComponent {} + `.trim(), + expectedFeatures: ['ɵɵdefer', 'ɵɵdeferOnViewport'], + }, + { + name: 'defer-timer-fractional-ms', + category: 'defer', + description: '@defer timer with fractional milliseconds (1.5ms)', + className: 'DeferTimerFractionalMsComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-defer-timer-fractional-ms', + standalone: true, + template: \` + @defer (on timer(1.5ms)) { +
Timer triggered
+ } + \`, +}) +export class DeferTimerFractionalMsComponent {} + `.trim(), + expectedFeatures: ['ɵɵdefer', 'ɵɵdeferOnTimer'], + }, + { + name: 'defer-timer-fractional-s', + category: 'defer', + description: '@defer timer with fractional seconds (1.5s = 1500ms)', + className: 'DeferTimerFractionalSComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-defer-timer-fractional-s', + standalone: true, + template: \` + @defer (on timer(1.5s)) { +
Timer triggered
+ } + \`, +}) +export class DeferTimerFractionalSComponent {} + `.trim(), + expectedFeatures: ['ɵɵdefer', 'ɵɵdeferOnTimer'], + }, +]