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
21 changes: 13 additions & 8 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2884,9 +2884,11 @@ fn create_binary_modulo<'a>(
/// Creates one CREATE op per case (ConditionalOp for first, ConditionalBranchCreateOp for rest)
/// and one UPDATE op (ConditionalUpdateOp) containing all conditions.
///
/// IMPORTANT: Angular always processes @default LAST, regardless of where it appears in
/// the template. This affects slot allocation and function naming. To match Angular's behavior,
/// we reorder the groups to put the @default group at the end before processing.
/// Angular's `ingestSwitchBlock` in `ingest.ts` iterates groups in source order, but the
/// `generateConditionalExpressions` phase later splices `@default` out and uses it as the
/// ternary fallback. Because the Rust pipeline's conditional codegen expects `@default` last,
/// we reorder here so that slot allocation, function naming, and the conditional expression
/// all match Angular's compiled output.
///
/// Ported from Angular's `ingestSwitchBlock` in `ingest.ts`.
fn ingest_switch_block<'a>(
Expand All @@ -2904,15 +2906,17 @@ fn ingest_switch_block<'a>(
// Convert the main switch expression as the test
let test = convert_ast_to_ir(job, switch_block.expression);

// Reorder groups to put @default LAST, matching Angular's behavior.
// Angular always assigns higher slot numbers to @default regardless of template order.
// A group is considered the @default group if ALL its cases have expression: None.
// Reorder groups to put @default LAST, matching Angular's compiled output.
// While Angular's ingestSwitchBlock iterates in source order, the downstream
// generateConditionalExpressions phase (conditionals.ts) splices @default out and
// uses it as the ternary fallback base. Because slot allocation and function naming
// happen after ingest, moving @default last here ensures our xref/slot/function
// ordering matches Angular's final output.
let mut groups_vec: std::vec::Vec<_> = switch_block.groups.into_iter().collect();
let default_idx = groups_vec.iter().position(|group| {
!group.cases.is_empty() && group.cases.iter().all(|c| c.expression.is_none())
});
if let Some(idx) = default_idx {
// Move the default group to the end
let default_group = groups_vec.remove(idx);
groups_vec.push(default_group);
}
Expand Down Expand Up @@ -3432,6 +3436,7 @@ fn ingest_defer_triggers<'a>(

// Handle viewport trigger
if let Some(viewport_trigger) = triggers.viewport {
let options = viewport_trigger.options.map(|opts| convert_ast_to_ir(job, opts));
let op = CreateOp::DeferOn(DeferOnOp {
base: CreateOpBase {
source_span: Some(viewport_trigger.source_span),
Expand All @@ -3446,7 +3451,7 @@ fn ingest_defer_triggers<'a>(
target_slot_view_steps: None,
target_name: viewport_trigger.reference,
delay: None,
options: None,
options,
});
if let Some(view) = job.view_mut(view_xref) {
view.create.push(op);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::ir::ops::{CreateOp, Op};
use crate::output::ast::{
ArrowFunctionBody, ArrowFunctionExpr as OutputArrowFunctionExpr, FnParam,
};
use crate::pipeline::compilation::ComponentCompilationJob;
use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilationJob};
use oxc_allocator::{Box as AllocBox, Vec as AllocVec};

/// Finds arrow functions written by the user and converts them into
Expand Down Expand Up @@ -123,7 +123,9 @@ fn convert_output_arrow_to_ir<'a>(
// The expression syntax doesn't support multi-line arrow functions,
// but the output AST does. We don't need to handle them here if
// the user isn't able to write one.
// In TypeScript this throws an error, but we'll just skip it.
// Angular throws an assertion error here; we use debug_assert to
// catch any internal compiler bugs that produce this in debug builds.
debug_assert!(false, "unexpected multi-line arrow function in template expression");
return None;
}
};
Expand All @@ -143,6 +145,39 @@ fn convert_output_arrow_to_ir<'a>(
})
}

/// Generate arrow functions for host binding compilation.
///
/// Angular runs this phase for Kind.Both, meaning it applies to both
/// template and host compilations. Host bindings can contain arrow
/// function expressions (e.g., in @HostBinding values or event handlers).
pub fn generate_arrow_functions_for_host(job: &mut HostBindingCompilationJob<'_>) {
let allocator = job.allocator;

// Process create operations (skip listeners)
for op in job.root.create.iter_mut() {
if !is_listener_op(op) {
transform_expressions_in_create_op(
op,
&|expr, flags| {
add_arrow_function(expr, allocator, flags);
},
VisitorContextFlag::NONE,
);
}
}

// Process update operations
for op in job.root.update.iter_mut() {
transform_expressions_in_update_op(
op,
&|expr, flags| {
add_arrow_function(expr, allocator, flags);
},
VisitorContextFlag::NONE,
);
}
}

/// Collect arrow functions from a view's operations into its functions set.
fn collect_arrow_functions_from_view(
view: &mut crate::pipeline::compilation::ViewCompilationUnit<'_>,
Expand Down
6 changes: 3 additions & 3 deletions crates/oxc_angular_compiler/src/pipeline/phases/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,11 @@ pub static PHASES: &[Phase] = &[
run_host: None,
name: "createVariadicPipes",
},
// Phase 21: generateArrowFunctions (Template only)
// Phase 21: generateArrowFunctions (Both)
Phase {
kind: CompilationJobKind::Template,
kind: CompilationJobKind::Both,
run: generate_arrow_functions::generate_arrow_functions,
run_host: None,
run_host: Some(generate_arrow_functions::generate_arrow_functions_for_host),
name: "generateArrowFunctions",
},
// Phase 22: generatePureLiteralStructures (Both)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ pub fn merge_next_context_expressions(job: &mut ComponentCompilationJob<'_>) {

for xref in view_xrefs {
if let Some(view) = job.view_mut(xref) {
// Merge in arrow function op lists (matches Angular's unit.functions traversal)
for fn_ptr in view.functions.iter() {
// SAFETY: These pointers are valid for the duration of the compilation
let arrow_fn = unsafe { &mut **fn_ptr };
merge_next_contexts_in_handler_ops(&mut arrow_fn.ops);
}

// Merge in create ops for listeners
for op in view.create.iter_mut() {
match op {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,19 @@ pub fn create_defer_on_stmt<'a>(
if let Some(opts) = options {
args.push(opts);
}
} else if let Some(slot) = target_slot {
// Regular/Prefetch viewport with explicit target: target_slot, target_slot_view_steps?, options?
args.push(OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Number(slot as f64), source_span: None },
allocator,
)));
} else {
// Always emit the first arg: slot number or null if unresolved.
// Angular: o.literal(op.trigger.targetSlot?.slot ?? null)
args.push(match target_slot {
Some(slot) => OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Number(slot as f64), source_span: None },
allocator,
)),
None => OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Null, source_span: None },
allocator,
)),
});

let view_steps = target_slot_view_steps.unwrap_or(0);
if view_steps != 0 {
Expand All @@ -253,28 +260,32 @@ pub fn create_defer_on_stmt<'a>(
args.push(opts);
}
}
// No arguments when no explicit target is specified
}
DeferTriggerKind::Interaction | DeferTriggerKind::Hover => {
// Hydrate triggers don't support targets
if modifier != DeferOpModifierKind::Hydrate {
// Only push arguments if there's an explicit target
if let Some(slot) = target_slot {
args.push(OutputExpression::Literal(Box::new_in(
// Always emit the first arg: slot number or null if unresolved.
// Angular: o.literal(op.trigger.targetSlot?.slot ?? null)
args.push(match target_slot {
Some(slot) => OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Number(slot as f64), source_span: None },
allocator,
)));
)),
None => OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Null, source_span: None },
allocator,
)),
});

let view_steps = target_slot_view_steps.unwrap_or(0);
if view_steps != 0 {
args.push(OutputExpression::Literal(Box::new_in(
LiteralExpr {
value: LiteralValue::Number(view_steps as f64),
source_span: None,
},
allocator,
)));
}
let view_steps = target_slot_view_steps.unwrap_or(0);
if view_steps != 0 {
args.push(OutputExpression::Literal(Box::new_in(
LiteralExpr {
value: LiteralValue::Number(view_steps as f64),
source_span: None,
},
allocator,
)));
}
}
}
Expand Down
61 changes: 52 additions & 9 deletions crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2313,21 +2313,36 @@ impl<'a> HtmlToR3Transform<'a> {
let mut unknown_blocks = Vec::new_in(self.allocator);
let mut collected_cases: std::vec::Vec<R3SwitchBlockCase<'a>> = std::vec::Vec::new();
let mut first_case_start: Option<Span> = None;
let mut has_default = false;

for child in &block.children {
// Skip non-block nodes (only process block children)
// Skip comments and whitespace-only text nodes (same as Angular)
match child {
HtmlNode::Comment(_) => continue,
HtmlNode::Text(t) if t.value.trim().is_empty() => continue,
_ => {}
}

// Non-block children (elements, non-whitespace text, etc.) are invalid
let child_block = match child {
HtmlNode::Block(b) => b,
_ => continue,
_ => {
self.report_error(
"@switch block can only contain @case and @default blocks",
child.span(),
);
continue;
}
};

// Check if this is a valid case/default block
// Note: @case with no parameters is treated as unknown (same as Angular)
let is_case =
child_block.block_type == BlockType::Case && !child_block.parameters.is_empty();
let is_default = child_block.block_type == BlockType::Default;

if !is_case && !is_default {
// Validate: only @case and @default are allowed inside @switch
if child_block.block_type != BlockType::Case
&& child_block.block_type != BlockType::Default
{
self.report_error(
"@switch block can only contain @case and @default blocks",
child_block.span,
);
unknown_blocks.push(crate::ast::r3::R3UnknownBlock {
name: child_block.name.clone(),
source_span: child_block.span,
Expand All @@ -2336,6 +2351,34 @@ impl<'a> HtmlToR3Transform<'a> {
continue;
}

let is_default = child_block.block_type == BlockType::Default;

// Validate @default
if is_default {
if has_default {
self.report_error(
"@switch block can only have one @default block",
child_block.start_span,
);
} else if !child_block.parameters.is_empty() {
self.report_error(
"@default block cannot have parameters",
child_block.start_span,
);
}
has_default = true;
}

// Validate @case: must have exactly one parameter
let is_case = child_block.block_type == BlockType::Case;
if is_case && child_block.parameters.len() != 1 {
self.report_error(
"@case block must have exactly one parameter",
child_block.start_span,
);
continue;
}

// Parse expression for @case blocks
let case_expression = if is_case {
let expr_str = child_block.parameters[0].expression.as_str();
Expand Down
4 changes: 3 additions & 1 deletion crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ fn test_switch_block() {

#[test]
fn test_switch_block_default_first() {
// Test @switch with @default appearing first - Angular should reorder to put @default last
// Test @switch with @default appearing first - Angular reorders @default last
// Angular's ingestSwitchBlock iterates in source order, but generateConditionalExpressions
// splices @default out as the ternary fallback. We reorder at ingest to match the final output.
let js = compile_template_to_js(
r"@switch (value) { @default { <div>Other</div> } @case (1) { <div>One</div> } @case (2) { <div>Two</div> } }",
"TestComponent",
Expand Down
Loading
Loading