Skip to content

OutputEmitterRef.emit() skips listeners when an earlier listener unsubscribes synchronously (e.g. outputToObservable + take(1)) #69325

@webberig

Description

@webberig

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.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions