Skip to content

Commit fe1d474

Browse files
committed
feat(core): add unsafeListenToOutput & listenToOuput to ComponentRef
This commit adds 2 methods to the `ComponentRef` class that allows to listen to outputs. It abstracts the underlying `EventEmitter` or `OutputEmitterRef` and returns a anonymous function to unsubscribe.
1 parent 368d69e commit fe1d474

File tree

10 files changed

+241
-6
lines changed

10 files changed

+241
-6
lines changed

goldens/public-api/core/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,11 @@ export abstract class ComponentRef<C> {
335335
abstract get hostView(): ViewRef;
336336
abstract get injector(): Injector;
337337
abstract get instance(): C;
338+
abstract listenToOutput<OutputType extends keyof C>(propertyName: OutputType, listenerFn: (eventArg: ExtractOutputType<C[OutputType]>) => unknown): () => void;
338339
abstract get location(): ElementRef;
339340
abstract onDestroy(callback: Function): void;
340341
abstract setInput(name: string, value: unknown): void;
342+
abstract unsafeListenToOutput(outputName: string, listenerFn: (eventArg: unknown) => unknown): () => void;
341343
}
342344

343345
// @public

packages/core/src/linker/component_factory.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
import type {ChangeDetectorRef} from '../change_detection/change_detection';
1010
import type {Injector} from '../di/injector';
1111
import type {EnvironmentInjector} from '../di/r3_injector';
12+
import type {OutputEmitterRef} from '../authoring';
13+
import type {EventEmitter} from '../event_emitter';
1214
import {Type} from '../interface/type';
1315

1416
import type {ElementRef} from './element_ref';
1517
import type {NgModuleRef} from './ng_module_factory';
1618
import type {ViewRef} from './view_ref';
1719

20+
export type ExtractOutputType<T> =
21+
T extends OutputEmitterRef<infer U> ? U : T extends EventEmitter<infer U> ? U : never;
22+
1823
/**
1924
* Represents a component created by a `ComponentFactory`.
2025
* Provides access to the component instance and related objects,
@@ -33,6 +38,34 @@ export abstract class ComponentRef<C> {
3338
*/
3439
abstract setInput(name: string, value: unknown): void;
3540

41+
/**
42+
* Listen to a given output on the component.
43+
*
44+
* @param propertyName the output property to listen to
45+
* @param listenerFn the callback invoked everytime the output fires the event
46+
*
47+
* @returns a function to manually clean up the output listening
48+
*/
49+
50+
abstract listenToOutput<OutputType extends keyof C>(
51+
propertyName: OutputType,
52+
listenerFn: (eventArg: ExtractOutputType<C[OutputType]>) => unknown,
53+
): () => void;
54+
55+
/**
56+
* Listen to a given output on the component.
57+
* This method does not ensure type safety of the output name nor of the event type.
58+
*
59+
* @param outputName the output to listen to
60+
* @param listenerFn the callback invoked everytime the output fires the event
61+
*
62+
* @returns a function to manually clean up the output listening
63+
*/
64+
abstract unsafeListenToOutput(
65+
outputName: string,
66+
listenerFn: (eventArg: unknown) => unknown,
67+
): () => void;
68+
3669
/**
3770
* The host or anchor element for this component instance.
3871
*/

packages/core/src/render3/component_ref.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {Type} from '../interface/type';
2222
import {
2323
ComponentFactory as AbstractComponentFactory,
2424
ComponentRef as AbstractComponentRef,
25+
ExtractOutputType,
2526
} from '../linker/component_factory';
2627
import {ComponentFactoryResolver as AbstractComponentFactoryResolver} from '../linker/component_factory_resolver';
2728
import {createElementRef, ElementRef} from '../linker/element_ref';
@@ -53,7 +54,12 @@ import {
5354
markAsComponentHost,
5455
setInputsForProperty,
5556
} from './instructions/shared';
56-
import {ComponentDef, DirectiveDef, HostDirectiveDefs} from './interfaces/definition';
57+
import {
58+
ComponentDef,
59+
ComponentType,
60+
DirectiveDef,
61+
HostDirectiveDefs,
62+
} from './interfaces/definition';
5763
import {InputFlags} from './interfaces/input_flags';
5864
import {
5965
NodeInputBindings,
@@ -91,6 +97,7 @@ import {ChainedInjector} from './chained_injector';
9197
import {unregisterLView} from './interfaces/lview_tracking';
9298
import {profiler} from './profiler';
9399
import {ProfilerEvent} from './profiler_types';
100+
import {listenToOutput} from './instructions/listener';
94101

95102
export class ComponentFactoryResolver extends AbstractComponentFactoryResolver {
96103
/**
@@ -479,6 +486,30 @@ export class ComponentRef<T> extends AbstractComponentRef<T> {
479486
}
480487
}
481488

489+
override listenToOutput<OutputType extends keyof T>(
490+
propertyName: OutputType,
491+
listenerFn: (eventArg: any) => unknown,
492+
): () => void {
493+
return this.unsafeListenToOutput(propertyName as string, listenerFn);
494+
}
495+
496+
override unsafeListenToOutput(
497+
outputName: string,
498+
listenerFn: (eventArg: unknown) => unknown,
499+
): () => void {
500+
const subscription = listenToOutput(this._tNode, this._rootLView, outputName, listenerFn);
501+
if (!subscription) {
502+
// TODO: create a runtime error
503+
throw new Error(
504+
ngDevMode
505+
? `${outputName} is not an output of the component ${this.componentType.name}`
506+
: '',
507+
);
508+
}
509+
510+
return subscription!;
511+
}
512+
482513
override get injector(): Injector {
483514
return new NodeInjector(this._tNode, this._rootLView);
484515
}

packages/core/src/render3/instructions/listener.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,25 @@ export function listenerInternal(
230230
}
231231

232232
// subscribe to directive outputs
233+
if (processOutputs) {
234+
listenToOutput(tNode, lView, eventName, listenerFn, lCleanup, tCleanup);
235+
}
236+
}
237+
238+
export function listenToOutput(
239+
tNode: TNode,
240+
lView: LView,
241+
eventName: string,
242+
listenerFn: (e: unknown) => unknown,
243+
lCleanup?: any[],
244+
tCleanup?: false | any[],
245+
): VoidFunction | undefined {
233246
const outputs = tNode.outputs;
234247
let props: NodeOutputBindings[keyof NodeOutputBindings] | undefined;
235-
if (processOutputs && outputs !== null && (props = outputs[eventName])) {
248+
if (outputs !== null && (props = outputs[eventName])) {
236249
const propsLength = props.length;
237250
if (propsLength) {
251+
const subscriptions: {unsubscribe: () => void}[] = [];
238252
for (let i = 0; i < propsLength; i += 2) {
239253
const index = props[i] as number;
240254
ngDevMode && assertIndexInRange(lView, index);
@@ -249,12 +263,19 @@ export function listenerInternal(
249263
}
250264

251265
const subscription = (output as SubscribableOutput<unknown>).subscribe(listenerFn);
252-
const idx = lCleanup.length;
253-
lCleanup.push(listenerFn, subscription);
254-
tCleanup && tCleanup.push(eventName, tNode.index, idx, -(idx + 1));
266+
if (lCleanup) {
267+
const idx = lCleanup.length;
268+
lCleanup.push(listenerFn, subscription);
269+
tCleanup && tCleanup.push(eventName, tNode.index, idx, -(idx + 1));
270+
}
271+
subscriptions.push(subscription);
255272
}
273+
return () => {
274+
subscriptions.forEach((s) => s.unsubscribe());
275+
};
256276
}
257277
}
278+
return undefined;
258279
}
259280

260281
function executeListenerWithErrorHandling(

packages/core/test/acceptance/authoring/output_function_spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,46 @@ describe('output() function', () => {
208208
expect(dir.effectCount).toEqual(1);
209209
});
210210

211+
it('should ensure type safety for listenToOutput', () => {
212+
@Component({template: ``, standalone: true})
213+
class MyComponent {
214+
outputRefString = output<{foo: string}>();
215+
}
216+
217+
const fixture = TestBed.createComponent(MyComponent);
218+
219+
let foo: {foo: string};
220+
fixture.componentRef.listenToOutput('outputRefString', (event) => {
221+
foo = event;
222+
});
223+
224+
// Testing an non matching type throws
225+
let bar: {bar: string};
226+
fixture.componentRef.listenToOutput('outputRefString', (event) => {
227+
/* @ts-expect-error */
228+
bar = event;
229+
});
230+
});
231+
232+
it('should emit on listenToOutput', () => {
233+
@Component({template: ``, standalone: true})
234+
class MyComponent {
235+
outputRefString = output<string>();
236+
}
237+
238+
const fixture = TestBed.createComponent(MyComponent);
239+
const logs: string[] = [];
240+
241+
fixture.componentRef.listenToOutput('outputRefString', (event) => {
242+
logs.push(event);
243+
});
244+
245+
fixture.componentInstance.outputRefString.emit('emitted');
246+
247+
expect(logs.length).toBe(1);
248+
expect(logs[0]).toEqual('emitted');
249+
});
250+
211251
describe('outputFromObservable()', () => {
212252
it('should support using a `Subject` as source', () => {
213253
@Directive({

packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@
495495
"leaveDI",
496496
"leaveView",
497497
"leaveViewLight",
498+
"listenToOutput",
498499
"lookupTokenUsingModuleInjector",
499500
"lookupTokenUsingNodeInjector",
500501
"makeParamDecorator",

packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@
481481
"leaveDI",
482482
"leaveView",
483483
"leaveViewLight",
484+
"listenToOutput",
484485
"listenerInternal",
485486
"lookupTokenUsingModuleInjector",
486487
"lookupTokenUsingNodeInjector",

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@
570570
"leaveDI",
571571
"leaveView",
572572
"leaveViewLight",
573+
"listenToOutput",
573574
"locateDirectiveOrProvider",
574575
"lookupTokenUsingModuleInjector",
575576
"lookupTokenUsingNodeInjector",

packages/core/test/bundling/todo/bundle.golden_symbols.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@
402402
"leaveDI",
403403
"leaveView",
404404
"leaveViewLight",
405+
"listenToOutput",
405406
"lookupTokenUsingModuleInjector",
406407
"lookupTokenUsingNodeInjector",
407408
"makeParamDecorator",

packages/core/test/render3/component_ref_spec.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ComponentRef} from '@angular/core';
9+
import {ComponentRef, inject} from '@angular/core';
1010
import {ComponentFactoryResolver} from '@angular/core/src/render3/component_ref';
1111
import {Renderer} from '@angular/core/src/render3/interfaces/renderer';
1212
import {RElement} from '@angular/core/src/render3/interfaces/renderer_dom';
@@ -15,10 +15,13 @@ import {TestBed} from '@angular/core/testing';
1515
import {
1616
ChangeDetectionStrategy,
1717
Component,
18+
Directive,
19+
EventEmitter,
1820
Injector,
1921
Input,
2022
NgModuleRef,
2123
OnChanges,
24+
output,
2225
Output,
2326
RendererType2,
2427
SimpleChanges,
@@ -467,4 +470,105 @@ describe('ComponentFactory', () => {
467470
expect(fixture.nativeElement.innerText).toBe('2');
468471
});
469472
});
473+
474+
describe('listen', () => {
475+
it('should allow listening to on the ComponentRef', () => {
476+
const logs: string[] = [];
477+
478+
@Directive({standalone: true})
479+
class MyDirective {
480+
@Output() onDirChange = new EventEmitter<void>();
481+
@Output() onChange = new EventEmitter<void>();
482+
483+
emit() {
484+
this.onChange.emit();
485+
this.onDirChange.emit();
486+
}
487+
}
488+
489+
@Component({
490+
template: `{{in}}`,
491+
hostDirectives: [{directive: MyDirective, outputs: ['onDirChange', 'onChange']}],
492+
standalone: true,
493+
})
494+
class MyComponent {
495+
@Output() onChange = new EventEmitter<void>();
496+
@Output('onSomeOtherChange') onOtherChange = new EventEmitter<void>();
497+
498+
private readonly directive: MyDirective = inject(MyDirective, {self: true});
499+
500+
emit() {
501+
this.onChange.emit();
502+
this.onOtherChange.emit();
503+
this.directive.emit();
504+
}
505+
}
506+
507+
const fixture = TestBed.createComponent(MyComponent);
508+
fixture.detectChanges();
509+
510+
const cleanUp1 = fixture.componentRef.unsafeListenToOutput('onChange', () => {
511+
logs.push('onChange');
512+
});
513+
const cleanUp2 = fixture.componentRef.unsafeListenToOutput('onSomeOtherChange', () => {
514+
logs.push('onSomeOtherChange');
515+
});
516+
const cleanUp3 = fixture.componentRef.unsafeListenToOutput('onDirChange', () => {
517+
logs.push('onDirChange');
518+
});
519+
520+
expect(logs.length).toBe(0);
521+
522+
fixture.componentInstance.emit();
523+
expect(logs.length).toBe(4);
524+
// Emited by the component
525+
expect(logs.at(-4)).toBe('onChange');
526+
expect(logs.at(-3)).toBe('onSomeOtherChange');
527+
528+
// Emited by the HostDirective
529+
expect(logs.at(-2)).toBe('onChange');
530+
expect(logs.at(-1)).toBe('onDirChange');
531+
532+
cleanUp1();
533+
cleanUp2();
534+
cleanUp3();
535+
fixture.componentInstance.emit();
536+
expect(logs.length).toBe(4);
537+
});
538+
539+
it('should ensure type safety for listenToOutput', () => {
540+
@Component({template: ``, standalone: true})
541+
class MyComponent {
542+
@Output() eventEmitterString = new EventEmitter<{foo: string}>();
543+
}
544+
545+
const fixture = TestBed.createComponent(MyComponent);
546+
547+
let foo: {foo: string};
548+
fixture.componentRef.listenToOutput('eventEmitterString', (event) => {
549+
foo = event;
550+
});
551+
552+
// Testing an non matching type throws
553+
let bar: {bar: string};
554+
fixture.componentRef.listenToOutput('eventEmitterString', (event) => {
555+
/* @ts-expect-error */
556+
bar = event;
557+
});
558+
});
559+
560+
it('should throw when listening a non-existing output', () => {
561+
const logs: string[] = [];
562+
563+
@Component({template: `{{in}}`})
564+
class DynamicCmp {
565+
notAnInput: any;
566+
}
567+
568+
const fixture = TestBed.createComponent(DynamicCmp);
569+
fixture.detectChanges();
570+
571+
expect(() => fixture.componentRef.unsafeListenToOutput('notAnOutput', () => {})).toThrow();
572+
});
573+
});
470574
});

0 commit comments

Comments
 (0)