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
69 changes: 32 additions & 37 deletions crates/oxc_angular_compiler/src/directive/property_decorators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,68 +313,63 @@ fn try_parse_signal_model<'a>(

/// Try to detect and parse a signal-based output from a property initializer.
///
/// Signal-based outputs are created by calling `output()` or `output<T>()`
/// from `@angular/core`. Unlike `model()`, they only create an output (no input).
/// Signal-based outputs are created by calling `output()` or `output<T>()` from
/// `@angular/core`, or `outputFromObservable()` from `@angular/core/rxjs-interop`.
/// Unlike `model()`, they only create an output (no input).
///
/// # Examples
/// ```typescript
/// readonly openChange = output<boolean>(); // creates output 'openChange'
/// readonly clicked = output(); // creates output 'clicked'
/// readonly aliased = output<string>({ alias: 'myAlias' }); // creates output 'myAlias'
/// ```
/// For `output()` the options object is the first argument.
/// For `outputFromObservable(observable, options?)` the options are the second argument;
/// the observable expression is irrelevant for metadata extraction.
///
/// Based on Angular's `output_function.ts` in the compiler-cli.
fn try_parse_signal_output<'a>(
value: &Expression<'a>,
property_name: Ident<'a>,
) -> Option<(Ident<'a>, Ident<'a>)> {
// Check if the value is a call expression
let call_expr = match value {
Expression::CallExpression(call) => call,
_ => return None,
};

// Check if this is output() - note that output() does NOT support .required()
let is_output = match &call_expr.callee {
// output() - simple identifier call
Expression::Identifier(id) if id.name == "output" => true,
// Handle namespaced calls like `core.output()`
// Detect which output initializer is called and whether options are at index 0 or 1.
let is_from_observable = match &call_expr.callee {
Expression::Identifier(id) => match id.name.as_str() {
"output" => false,
"outputFromObservable" => true,
_ => return None,
},
// Handle namespaced calls like `core.output()` or `rxjs.outputFromObservable()`
Expression::StaticMemberExpression(member) => {
if member.property.name == "output" {
matches!(&member.object, Expression::Identifier(_))
} else {
false
if !matches!(&member.object, Expression::Identifier(_)) {
return None;
}
match member.property.name.as_str() {
"output" => false,
"outputFromObservable" => true,
_ => return None,
}
}
_ => false,
_ => return None,
};

if !is_output {
return None;
}

// Parse options from the first argument (options are the first arg for output())
// Options can contain an alias
// output() → options at index 0; outputFromObservable(obs, options?) → options at index 1
let options_idx = if is_from_observable { 1 } else { 0 };
let mut alias: Option<Ident<'a>> = None;

if let Some(first_arg) = call_expr.arguments.first() {
if let Argument::ObjectExpression(obj) = first_arg {
for prop in &obj.properties {
if let ObjectPropertyKind::ObjectProperty(prop) = prop {
let Some(key_name) = get_property_key_name(&prop.key) else {
continue;
};

if key_name.as_str() == "alias" {
alias = extract_string_value(&prop.value);
}
if let Some(Argument::ObjectExpression(obj)) = call_expr.arguments.get(options_idx) {
for prop in &obj.properties {
if let ObjectPropertyKind::ObjectProperty(prop) = prop {
let Some(key_name) = get_property_key_name(&prop.key) else {
continue;
};
if key_name.as_str() == "alias" {
alias = extract_string_value(&prop.value);
}
}
}
}

let binding_property_name = alias.unwrap_or_else(|| property_name.clone());

Some((property_name, binding_property_name))
}

Expand Down
165 changes: 165 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9441,3 +9441,168 @@ export class LoggedPipe implements PipeTransform {
"Pipe factory should fall back to the type annotation (Logger). Factory:\n{factory_section}"
);
}

// ============================================================================
// outputFromObservable tests
// ============================================================================

#[test]
fn test_output_from_observable_simple() {
let allocator = Allocator::default();
let source = r#"
import { Component, EventEmitter } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({
selector: 'test-comp',
standalone: true,
template: '',
})
export class TestComponent {
readonly queryChanged = outputFromObservable(new EventEmitter<string>());
}
"#;
let result = transform_angular_file(&allocator, "test.component.ts", source, None, None);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

let normalized = result.code.replace([' ', '\n', '\t'], "");
assert!(
normalized.contains(r#"ɵɵdefineComponent("#)
&& normalized.contains(r#"outputs:{queryChanged:"queryChanged"}"#),
"outputFromObservable property should appear in outputs:{{}} inside ɵɵdefineComponent.\nCode:\n{}",
result.code
);
insta::assert_snapshot!("output_from_observable_simple", result.code);
}

#[test]
fn test_output_from_observable_property_reference() {
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({
selector: 'test-comp',
standalone: true,
template: '',
})
export class TestComponent {
readonly valueChanged = outputFromObservable(this.someService.value$);
}
"#;
let result = transform_angular_file(&allocator, "test.component.ts", source, None, None);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

let normalized = result.code.replace([' ', '\n', '\t'], "");
assert!(
normalized.contains(r#"ɵɵdefineComponent("#)
&& normalized.contains(r#"outputs:{valueChanged:"valueChanged"}"#),
"outputFromObservable with property reference should appear in outputs:{{}} inside ɵɵdefineComponent.\nCode:\n{}",
result.code
);
insta::assert_snapshot!("output_from_observable_property_reference", result.code);
}

#[test]
fn test_output_from_observable_piped() {
// Regression: the reported real-world case — complex piped observable as argument.
let allocator = Allocator::default();
let source = r#"
import { Component } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({
selector: 'test-comp',
standalone: true,
template: '',
})
export class TestComponent {
readonly queryChanged = outputFromObservable(
this.dataService.value$.pipe(
skip(1),
debounceTime(300),
),
);
}
"#;
let result = transform_angular_file(&allocator, "test.component.ts", source, None, None);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

let normalized = result.code.replace([' ', '\n', '\t'], "");
assert!(
normalized.contains(r#"ɵɵdefineComponent("#)
&& normalized.contains(r#"outputs:{queryChanged:"queryChanged"}"#),
"outputFromObservable with piped observable should appear in outputs:{{}} inside ɵɵdefineComponent.\nCode:\n{}",
result.code
);
insta::assert_snapshot!("output_from_observable_piped", result.code);
}

#[test]
fn test_output_from_observable_with_alias() {
// The alias option is the second argument; the class property name maps to the alias binding name.
let allocator = Allocator::default();
let source = r#"
import { Component, EventEmitter } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({
selector: 'test-comp',
standalone: true,
template: '',
})
export class TestComponent {
readonly _clicked = outputFromObservable(new EventEmitter<void>(), { alias: 'clicked' });
}
"#;
let result = transform_angular_file(&allocator, "test.component.ts", source, None, None);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

let normalized = result.code.replace([' ', '\n', '\t'], "");
// Class property name '_clicked' must map to binding name 'clicked' (the alias value).
assert!(
normalized.contains(r#"ɵɵdefineComponent("#)
&& normalized.contains(r#"outputs:{_clicked:"clicked"}"#),
"outputFromObservable alias should become the binding property name in outputs:{{}}.\nCode:\n{}",
result.code
);
// Also verify the class property name itself is NOT used as the binding name.
assert!(
!normalized.contains(r#"outputs:{_clicked:"_clicked"}"#),
"Class property name '_clicked' should NOT be used as binding name when alias is set.\nCode:\n{}",
result.code
);
insta::assert_snapshot!("output_from_observable_with_alias", result.code);
}

#[test]
fn test_output_from_observable_mixed_with_output() {
// Both output() and outputFromObservable() in the same class must both appear in outputs.
let allocator = Allocator::default();
let source = r#"
import { Component, EventEmitter, output } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({
selector: 'test-comp',
standalone: true,
template: '',
})
export class TestComponent {
readonly clicked = output<void>();
readonly queryChanged = outputFromObservable(new EventEmitter<string>());
}
"#;
let result = transform_angular_file(&allocator, "test.component.ts", source, None, None);
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);

let normalized = result.code.replace([' ', '\n', '\t'], "");
assert!(
normalized.contains(r#"ɵɵdefineComponent("#)
&& normalized.contains(r#"outputs:{clicked:"clicked",queryChanged:"queryChanged"}"#),
"Both output() and outputFromObservable() must appear in outputs:{{}} inside ɵɵdefineComponent.\nCode:\n{}",
result.code
);
insta::assert_snapshot!("output_from_observable_mixed_with_output", result.code);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: result.code
---

import { Component, EventEmitter, output } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import * as i0 from '@angular/core';

export class TestComponent {
readonly clicked = output<void>();
readonly queryChanged = outputFromObservable(new EventEmitter<string>());

static ɵfac = function TestComponent_Factory(__ngFactoryType__) {
return new (__ngFactoryType__ || TestComponent)();
};
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selectors:[["test-comp"]],outputs:{clicked:"clicked",
queryChanged:"queryChanged"},decls:0,vars:0,template:function TestComponent_Template(rf,
ctx) {
},encapsulation:2});
}
(() =>{
(((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent,
{className:"TestComponent",filePath:"test.component.ts",lineNumber:10}));
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: result.code
---

import { Component } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import * as i0 from '@angular/core';

export class TestComponent {
readonly queryChanged = outputFromObservable(
this.dataService.value$.pipe(
skip(1),
debounceTime(300),
),
);

static ɵfac = function TestComponent_Factory(__ngFactoryType__) {
return new (__ngFactoryType__ || TestComponent)();
};
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selectors:[["test-comp"]],outputs:{queryChanged:"queryChanged"},
decls:0,vars:0,template:function TestComponent_Template(rf,ctx) {
},encapsulation:2});
}
(() =>{
(((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent,
{className:"TestComponent",filePath:"test.component.ts",lineNumber:10}));
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: result.code
---

import { Component } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import * as i0 from '@angular/core';

export class TestComponent {
readonly valueChanged = outputFromObservable(this.someService.value$);

static ɵfac = function TestComponent_Factory(__ngFactoryType__) {
return new (__ngFactoryType__ || TestComponent)();
};
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selectors:[["test-comp"]],outputs:{valueChanged:"valueChanged"},
decls:0,vars:0,template:function TestComponent_Template(rf,ctx) {
},encapsulation:2});
}
(() =>{
(((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent,
{className:"TestComponent",filePath:"test.component.ts",lineNumber:10}));
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: result.code
---

import { Component, EventEmitter } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import * as i0 from '@angular/core';

export class TestComponent {
readonly queryChanged = outputFromObservable(new EventEmitter<string>());

static ɵfac = function TestComponent_Factory(__ngFactoryType__) {
return new (__ngFactoryType__ || TestComponent)();
};
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selectors:[["test-comp"]],outputs:{queryChanged:"queryChanged"},
decls:0,vars:0,template:function TestComponent_Template(rf,ctx) {
},encapsulation:2});
}
(() =>{
(((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent,
{className:"TestComponent",filePath:"test.component.ts",lineNumber:10}));
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: result.code
---

import { Component, EventEmitter } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import * as i0 from '@angular/core';

export class TestComponent {
readonly _clicked = outputFromObservable(new EventEmitter<void>(), { alias: 'clicked' });

static ɵfac = function TestComponent_Factory(__ngFactoryType__) {
return new (__ngFactoryType__ || TestComponent)();
};
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selectors:[["test-comp"]],outputs:{_clicked:"clicked"},
decls:0,vars:0,template:function TestComponent_Template(rf,ctx) {
},encapsulation:2});
}
(() =>{
(((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassDebugInfo(TestComponent,
{className:"TestComponent",filePath:"test.component.ts",lineNumber:10}));
})();
Loading
Loading