From 560dab4347f32e8e315656aedffb5e3041bef753 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 1 May 2026 14:37:58 +0100 Subject: [PATCH 1/4] fix: support outputFromObservable() from @angular/core/rxjs-interop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit outputFromObservable() was not recognised as an output initializer, so properties declared with it were silently dropped from the outputs metadata in the compiled ɵɵdefineComponent/ɵɵdefineDirective call. Extend try_parse_signal_output to detect both output() and outputFromObservable(). The key difference: output() takes options as its first argument while outputFromObservable(observable, options?) takes them as its second — the observable expression itself is irrelevant for metadata extraction. Adds 5 unit tests covering: simple EventEmitter arg, direct property reference, piped observable chain (the reported real-world case), alias via second arg, and mixed usage with output(). Also adds an e2e compare fixture so the output can be verified against the official Angular compiler. Co-Authored-By: Claude Sonnet 4.6 --- .../src/directive/property_decorators.rs | 69 ++++---- .../tests/integration_test.rs | 151 ++++++++++++++++++ .../output-from-observable.fixture.ts | 103 ++++++++++++ 3 files changed, 286 insertions(+), 37 deletions(-) create mode 100644 napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 60f5f4de0..937d090ec 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -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()` -/// from `@angular/core`. Unlike `model()`, they only create an output (no input). +/// Signal-based outputs are created by calling `output()` or `output()` from +/// `@angular/core`, or `outputFromObservable()` from `@angular/core/rxjs-interop`. +/// Unlike `model()`, they only create an output (no input). /// -/// # Examples -/// ```typescript -/// readonly openChange = output(); // creates output 'openChange' -/// readonly clicked = output(); // creates output 'clicked' -/// readonly aliased = output({ 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> = 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)) } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 8d9763585..67c08c3e6 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -9441,3 +9441,154 @@ 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() { + // outputFromObservable with a simple new EventEmitter() should appear in outputs metadata + // and in setClassMetadata propDecorators. + 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()); +} +"#; + let result = transform_angular_file(&allocator, "test.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + assert!( + result.code.contains(r#"queryChanged:"queryChanged""#) + || result.code.contains(r#"queryChanged: "queryChanged""#), + "outputFromObservable property should appear in outputs metadata.\nCode:\n{}", + result.code + ); +} + +#[test] +fn test_output_from_observable_property_reference() { + // outputFromObservable with a direct property reference (this.service.obs$) should appear in outputs. + 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); + assert!( + result.code.contains(r#"valueChanged:"valueChanged""#) + || result.code.contains(r#"valueChanged: "valueChanged""#), + "outputFromObservable with property reference should appear in outputs metadata.\nCode:\n{}", + result.code + ); +} + +#[test] +fn test_output_from_observable_piped() { + // outputFromObservable with a piped observable (the reported real-world issue). + 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.queryEditorService.latestParsedDataprimeQuery$.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); + assert!( + result.code.contains(r#"queryChanged:"queryChanged""#) + || result.code.contains(r#"queryChanged: "queryChanged""#), + "outputFromObservable with piped observable should appear in outputs metadata.\nCode:\n{}", + result.code + ); +} + +#[test] +fn test_output_from_observable_with_alias() { + // outputFromObservable with alias in the second argument. + 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(), { alias: 'clicked' }); +} +"#; + let result = transform_angular_file(&allocator, "test.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + assert!( + result.code.contains(r#"_clicked:"clicked""#) + || result.code.contains(r#"_clicked: "clicked""#), + "outputFromObservable alias should be used as the binding property name.\nCode:\n{}", + result.code + ); +} + +#[test] +fn test_output_from_observable_mixed_with_output() { + // A class that uses both output() and outputFromObservable() should have both 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(); + readonly queryChanged = outputFromObservable(new EventEmitter()); +} +"#; + let result = transform_angular_file(&allocator, "test.component.ts", source, None, None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + assert!( + result.code.contains(r#"clicked:"clicked""#) || result.code.contains(r#"clicked: "clicked""#), + "output() property should appear in outputs metadata.\nCode:\n{}", + result.code + ); + assert!( + result.code.contains(r#"queryChanged:"queryChanged""#) + || result.code.contains(r#"queryChanged: "queryChanged""#), + "outputFromObservable property should also appear in outputs metadata.\nCode:\n{}", + result.code + ); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts new file mode 100644 index 000000000..ed9f560be --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts @@ -0,0 +1,103 @@ +/** + * Fixtures for outputFromObservable() from @angular/core/rxjs-interop. + * + * outputFromObservable() is equivalent to output() for metadata purposes — + * both register an output binding — but it wraps an existing RxJS observable + * instead of creating a new OutputEmitterRef. Options (e.g. alias) are passed + * as the *second* argument, unlike output() where they are the first. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + type: 'full-transform', + name: 'output-from-observable-simple', + category: 'inputs-outputs', + description: 'outputFromObservable with a simple EventEmitter should appear in outputs metadata', + className: 'OutputFromObservableSimpleComponent', + sourceCode: ` +import { Component, EventEmitter } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-output-from-observable-simple', + standalone: true, + template: '', +}) +export class OutputFromObservableSimpleComponent { + readonly queryChanged = outputFromObservable(new EventEmitter()); + readonly clicked = outputFromObservable(new EventEmitter()); +} +`.trim(), + }, + { + type: 'full-transform', + name: 'output-from-observable-piped', + category: 'inputs-outputs', + description: 'outputFromObservable with a piped observable chain should appear in outputs metadata', + className: 'OutputFromObservablePipedComponent', + sourceCode: ` +import { Component } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; +import { Subject } from 'rxjs'; +import { skip, debounceTime } from 'rxjs/operators'; + +@Component({ + selector: 'app-output-from-observable-piped', + standalone: true, + template: '', +}) +export class OutputFromObservablePipedComponent { + private query$ = new Subject(); + + readonly queryChanged = outputFromObservable( + this.query$.pipe( + skip(1), + debounceTime(300), + ), + ); +} +`.trim(), + }, + { + type: 'full-transform', + name: 'output-from-observable-alias', + category: 'inputs-outputs', + description: 'outputFromObservable with alias in second argument should use alias as binding name', + className: 'OutputFromObservableAliasComponent', + sourceCode: ` +import { Component, EventEmitter } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-output-from-observable-alias', + standalone: true, + template: '', +}) +export class OutputFromObservableAliasComponent { + readonly _clicked = outputFromObservable(new EventEmitter(), { alias: 'clicked' }); +} +`.trim(), + }, + { + type: 'full-transform', + name: 'output-from-observable-mixed', + category: 'inputs-outputs', + description: 'Class using both output() and outputFromObservable() should have both in outputs', + className: 'OutputFromObservableMixedComponent', + sourceCode: ` +import { Component, EventEmitter, output } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-output-from-observable-mixed', + standalone: true, + template: '', +}) +export class OutputFromObservableMixedComponent { + readonly clicked = output(); + readonly queryChanged = outputFromObservable(new EventEmitter()); +} +`.trim(), + }, +] From b389e86e787584dafe3d8a7bc5d6fd7815150dbb Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 1 May 2026 14:43:35 +0100 Subject: [PATCH 2/4] test: strengthen outputFromObservable assertions with structure checks and snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace loose contains() checks with normalized whitespace assertions that verify the exact outputs:{} object inside ɵɵdefineComponent, plus insta snapshots that lock the full compiled output. Also add a negative assertion for the alias test confirming the class property name is not used as the binding name when an alias is set. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/integration_test.rs | 66 +++++++++++-------- ...put_from_observable_mixed_with_output.snap | 25 +++++++ ...on_test__output_from_observable_piped.snap | 28 ++++++++ ...ut_from_observable_property_reference.snap | 23 +++++++ ...n_test__output_from_observable_simple.snap | 23 +++++++ ...st__output_from_observable_with_alias.snap | 23 +++++++ 6 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 67c08c3e6..f241f2a72 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -9448,8 +9448,6 @@ export class LoggedPipe implements PipeTransform { #[test] fn test_output_from_observable_simple() { - // outputFromObservable with a simple new EventEmitter() should appear in outputs metadata - // and in setClassMetadata propDecorators. let allocator = Allocator::default(); let source = r#" import { Component, EventEmitter } from '@angular/core'; @@ -9466,17 +9464,19 @@ export class TestComponent { "#; 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!( - result.code.contains(r#"queryChanged:"queryChanged""#) - || result.code.contains(r#"queryChanged: "queryChanged""#), - "outputFromObservable property should appear in outputs metadata.\nCode:\n{}", + 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() { - // outputFromObservable with a direct property reference (this.service.obs$) should appear in outputs. let allocator = Allocator::default(); let source = r#" import { Component } from '@angular/core'; @@ -9493,17 +9493,20 @@ export class TestComponent { "#; 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!( - result.code.contains(r#"valueChanged:"valueChanged""#) - || result.code.contains(r#"valueChanged: "valueChanged""#), - "outputFromObservable with property reference should appear in outputs metadata.\nCode:\n{}", + 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() { - // outputFromObservable with a piped observable (the reported real-world issue). + // Regression: the reported real-world case — complex piped observable as argument. let allocator = Allocator::default(); let source = r#" import { Component } from '@angular/core'; @@ -9525,17 +9528,20 @@ export class TestComponent { "#; 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!( - result.code.contains(r#"queryChanged:"queryChanged""#) - || result.code.contains(r#"queryChanged: "queryChanged""#), - "outputFromObservable with piped observable should appear in outputs metadata.\nCode:\n{}", + 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() { - // outputFromObservable with alias in the second argument. + // 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'; @@ -9552,17 +9558,27 @@ export class TestComponent { "#; 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!( - result.code.contains(r#"_clicked:"clicked""#) - || result.code.contains(r#"_clicked: "clicked""#), - "outputFromObservable alias should be used as the binding property name.\nCode:\n{}", + 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() { - // A class that uses both output() and outputFromObservable() should have both in outputs. + // 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'; @@ -9580,15 +9596,13 @@ export class TestComponent { "#; 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!( - result.code.contains(r#"clicked:"clicked""#) || result.code.contains(r#"clicked: "clicked""#), - "output() property should appear in outputs metadata.\nCode:\n{}", - result.code - ); - assert!( - result.code.contains(r#"queryChanged:"queryChanged""#) - || result.code.contains(r#"queryChanged: "queryChanged""#), - "outputFromObservable property should also appear in outputs metadata.\nCode:\n{}", + 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); } diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap new file mode 100644 index 000000000..1e2e10bd3 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap @@ -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(); + readonly queryChanged = outputFromObservable(new EventEmitter()); + +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})); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap new file mode 100644 index 000000000..3ccb61940 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap @@ -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.queryEditorService.latestParsedDataprimeQuery$.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})); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap new file mode 100644 index 000000000..a262b7d25 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap @@ -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})); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap new file mode 100644 index 000000000..098bcea65 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap @@ -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()); + +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})); +})(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap new file mode 100644 index 000000000..667f5a6b4 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap @@ -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(), { 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})); +})(); From f6ff87af0b77949df32bdfe0f0b2e9cb0415d5b9 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 1 May 2026 14:47:24 +0100 Subject: [PATCH 3/4] test: replace domain-specific name in piped observable test fixture Co-Authored-By: Claude Sonnet 4.6 --- crates/oxc_angular_compiler/tests/integration_test.rs | 2 +- .../integration_test__output_from_observable_piped.snap | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index f241f2a72..e65809f40 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -9519,7 +9519,7 @@ import { outputFromObservable } from '@angular/core/rxjs-interop'; }) export class TestComponent { readonly queryChanged = outputFromObservable( - this.queryEditorService.latestParsedDataprimeQuery$.pipe( + this.dataService.value$.pipe( skip(1), debounceTime(300), ), diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap index 3ccb61940..d4150cdeb 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap @@ -9,7 +9,7 @@ import * as i0 from '@angular/core'; export class TestComponent { readonly queryChanged = outputFromObservable( - this.queryEditorService.latestParsedDataprimeQuery$.pipe( + this.dataService.value$.pipe( skip(1), debounceTime(300), ), From 9de4c5e5f5681fce73263a7ff9c53ceaa97eb84e Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 1 May 2026 14:50:59 +0100 Subject: [PATCH 4/4] style: fix oxfmt formatting in output-from-observable fixture Co-Authored-By: Claude Sonnet 4.6 --- .../inputs-outputs/output-from-observable.fixture.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts index ed9f560be..768bf3664 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/output-from-observable.fixture.ts @@ -13,7 +13,8 @@ export const fixtures: Fixture[] = [ type: 'full-transform', name: 'output-from-observable-simple', category: 'inputs-outputs', - description: 'outputFromObservable with a simple EventEmitter should appear in outputs metadata', + description: + 'outputFromObservable with a simple EventEmitter should appear in outputs metadata', className: 'OutputFromObservableSimpleComponent', sourceCode: ` import { Component, EventEmitter } from '@angular/core'; @@ -34,7 +35,8 @@ export class OutputFromObservableSimpleComponent { type: 'full-transform', name: 'output-from-observable-piped', category: 'inputs-outputs', - description: 'outputFromObservable with a piped observable chain should appear in outputs metadata', + description: + 'outputFromObservable with a piped observable chain should appear in outputs metadata', className: 'OutputFromObservablePipedComponent', sourceCode: ` import { Component } from '@angular/core'; @@ -63,7 +65,8 @@ export class OutputFromObservablePipedComponent { type: 'full-transform', name: 'output-from-observable-alias', category: 'inputs-outputs', - description: 'outputFromObservable with alias in second argument should use alias as binding name', + description: + 'outputFromObservable with alias in second argument should use alias as binding name', className: 'OutputFromObservableAliasComponent', sourceCode: ` import { Component, EventEmitter } from '@angular/core';