Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/oxc_angular_compiler/src/ast/r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion crates/oxc_angular_compiler/src/ir/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,7 +1062,7 @@ pub struct DeferOnOp<'a> {
/// Target name for local ref targeting.
pub target_name: Option<Atom<'a>>,
/// Timer delay.
pub delay: Option<u32>,
pub delay: Option<f64>,
/// Viewport options (for viewport trigger).
pub options: Option<Box<'a, IrExpression<'a>>>,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ pub fn create_defer_on_stmt<'a>(
target_slot: Option<u32>,
target_slot_view_steps: Option<i32>,
modifier: DeferOpModifierKind,
delay: Option<u32>,
delay: Option<f64>,
options: Option<OutputExpression<'a>>,
) -> OutputStatement<'a> {
let mut args = OxcVec::new_in(allocator);
Expand All @@ -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,
)));
}
Expand Down
137 changes: 88 additions & 49 deletions crates/oxc_angular_compiler/src/transform/control_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,9 @@ pub struct ForLoopParams<'a> {
pub context_variables: Vec<'a, R3Variable<'a>>,
/// Parse errors.
pub errors: std::vec::Vec<String>,
/// 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.
Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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,
};
};

Expand All @@ -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,
};
};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 \"<name> = <variable name>\"".to_string()
Expand Down Expand Up @@ -700,7 +715,7 @@ fn strip_optional_parentheses(expr: &str, errors: &mut std::vec::Vec<String>) ->
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,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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<u32> {
/// Returns f64 to preserve fractional precision (matching Angular's parseFloat behavior).
fn parse_deferred_time(value: &str) -> Option<f64> {
let value = value.trim();

if !is_valid_time_pattern(value) {
Expand All @@ -1581,7 +1617,7 @@ fn parse_deferred_time(value: &str) -> Option<u32> {
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)]
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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: \`
<div #myRef>Trigger element</div>
@defer (on viewport({trigger: myRef})) {
<div>Deferred</div>
}
\`,
})
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)) {
<div>Timer triggered</div>
}
\`,
})
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)) {
<div>Timer triggered</div>
}
\`,
})
export class DeferTimerFractionalSComponent {}
`.trim(),
expectedFeatures: ['ɵɵdefer', 'ɵɵdeferOnTimer'],
},
]
Loading