Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
Yes
Description
When two listeners are subscribed to the same signal-based output(), and the first listener unsubscribes itself synchronously while receiving an emitted value, the second listener is silently skipped — it never receives the event.
This is a regression compared to decorator-based @Output() / EventEmitter: the exact same code works correctly there, as it does in every comparable event system (DOM EventTarget, Node EventEmitter, RxJS Subject). A listener removing itself is never supposed to affect delivery to other listeners. Since output() is presented as a drop-in replacement for @Output(), this behavioral difference is subtle and very hard to debug — in our app it manifested as a template event binding that simply never fired, with nothing in the console pointing at the cause.
Reproduction
https://stackblitz.com/edit/stackblitz-starters-scbzqgpg
A child component emits an output once:
@Component({ selector: 'app-child', /* ... */ })
export class ChildComponent implements OnInit {
ready = output<string>();
ngOnInit() {
this.ready.emit('hello from child');
}
}
A directive on the same element consumes that output once, the idiomatic way:
@Directive({ selector: 'app-child[consumeOnce]' })
export class ConsumeOnceDirective {
private host = inject(ChildComponent);
constructor() {
outputToObservable(this.host.ready)
.pipe(take(1))
.subscribe((v) => console.log('[directive] received:', v));
}
}
And the parent listens via a regular template binding:
<app-child consumeOnce (ready)="onReady($event)"></app-child>
Expected: both the directive and the parent's (ready) binding receive the event.
Actual: the directive receives it, but onReady is never called. Remove take(1) from the directive — or switch the child back to @Output() ready = new EventEmitter<string>() — and both fire again.
The StackBlitz renders the child twice, with and without the directive, so the broken and working cases can be compared side by side.
What's happening
OutputEmitterRef.emit() iterates its listeners array directly:
for (const listenerFn of this.listeners) {
try {
listenerFn(value);
} catch (err: unknown) {
this.errorHandler?.handleError(err);
}
}
while OutputRefSubscription.unsubscribe() removes a listener by splicing that same array:
unsubscribe: () => {
const idx = this.listeners?.indexOf(callback);
if (idx !== undefined && idx !== -1) {
this.listeners?.splice(idx, 1);
}
},
The directive's subscriber is registered before the template's event binding (directive instantiation precedes listener wiring on the element), so it sits at index
0 and the template binding at index 1. When the child emits, take(1) delivers the value and unsubscribes synchronously — during the emit loop. The splice
shifts the template binding down to index 0, the for...of iterator advances to index 1, and the binding is skipped.
Note that emit() already guards against listeners throwing (per-listener try/catch routed to ErrorHandler), so listeners affecting each other's delivery was
clearly considered — the synchronous-unsubscribe path was just missed.
Suggested fix
Dispatch over a snapshot of the listeners array:
for (const listenerFn of [...this.listeners]) {
(or collect unsubscriptions during dispatch and apply them afterwards, which also gives a defined semantic for listeners added during an emit).
Please provide a link to a minimal reproduction of the bug
https://stackblitz.com/edit/stackblitz-starters-scbzqgpg
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run ng version)
Reproduced on Angular 21.2.12; the code above is still present in `output_emitter_ref.ts` on `main`:
https://github.com/angular/angular/blob/main/packages/core/src/authoring/output/output_emitter_ref.ts
Anything else?
This issue is co-authored by Claude Code Fable, I made the stackblitz project and reproduced this manually before writing up the issue.
Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
Yes
Description
When two listeners are subscribed to the same signal-based
output(), and the first listener unsubscribes itself synchronously while receiving an emitted value, the second listener is silently skipped — it never receives the event.This is a regression compared to decorator-based
@Output()/EventEmitter: the exact same code works correctly there, as it does in every comparable event system (DOMEventTarget, NodeEventEmitter, RxJSSubject). A listener removing itself is never supposed to affect delivery to other listeners. Sinceoutput()is presented as a drop-in replacement for@Output(), this behavioral difference is subtle and very hard to debug — in our app it manifested as a template event binding that simply never fired, with nothing in the console pointing at the cause.Reproduction
https://stackblitz.com/edit/stackblitz-starters-scbzqgpg
A child component emits an output once:
A directive on the same element consumes that output once, the idiomatic way:
And the parent listens via a regular template binding:
Expected: both the directive and the parent's
(ready)binding receive the event.Actual: the directive receives it, but
onReadyis never called. Removetake(1)from the directive — or switch the child back to@Output() ready = new EventEmitter<string>()— and both fire again.The StackBlitz renders the child twice, with and without the directive, so the broken and working cases can be compared side by side.
What's happening
OutputEmitterRef.emit()iterates itslistenersarray directly:while
OutputRefSubscription.unsubscribe()removes a listener by splicing that same array:The directive's subscriber is registered before the template's event binding (directive instantiation precedes listener wiring on the element), so it sits at index
0 and the template binding at index 1. When the child emits,
take(1)delivers the value and unsubscribes synchronously — during the emit loop. The spliceshifts the template binding down to index 0, the
for...ofiterator advances to index 1, and the binding is skipped.Note that
emit()already guards against listeners throwing (per-listener try/catch routed toErrorHandler), so listeners affecting each other's delivery wasclearly considered — the synchronous-unsubscribe path was just missed.
Suggested fix
Dispatch over a snapshot of the listeners array:
(or collect unsubscriptions during dispatch and apply them afterwards, which also gives a defined semantic for listeners added during an emit).
Please provide a link to a minimal reproduction of the bug
https://stackblitz.com/edit/stackblitz-starters-scbzqgpg
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
This issue is co-authored by Claude Code Fable, I made the stackblitz project and reproduced this manually before writing up the issue.