Skip to content

fix(core): prevent unsubscribe during emit from throwing off other listeners#69327

Open
crisbeto wants to merge 1 commit into
angular:mainfrom
crisbeto:69325/output-emitter-array
Open

fix(core): prevent unsubscribe during emit from throwing off other listeners#69327
crisbeto wants to merge 1 commit into
angular:mainfrom
crisbeto:69325/output-emitter-array

Conversation

@crisbeto

Copy link
Copy Markdown
Member

Fixes that when a listener unsubscribes from an output within its own callback, it was preventing subsequent listeners from running.

These changes fix the issue by not mutating the array while the emit loop is running, but replacing the listener with null and coming back later to remove it.

Fixes #69325.

@crisbeto crisbeto added action: review The PR is still awaiting reviews from at least one requested reviewer target: patch This PR is targeted for the next patch release labels Jun 12, 2026
@angular-robot angular-robot Bot added the area: core Issues related to the framework runtime label Jun 12, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jun 12, 2026
@crisbeto crisbeto requested a review from atscott June 12, 2026 08:26
Comment on lines +37 to +38
private isEmitting = false;
private hasNullListeners = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like maybe time to store destroyed, isEmitting and hasNullListeners as bitflag

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, I feel like this a bit more readable I don't imagine it storing any more flags than this.

Comment thread packages/core/src/authoring/output/output_emitter_ref.ts Outdated
@crisbeto crisbeto force-pushed the 69325/output-emitter-array branch from 56e8aa1 to 0979294 Compare June 12, 2026 11:57
Comment thread packages/core/src/authoring/output/output_emitter_ref.ts Outdated

@atscott atscott left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Here is a suggestion to simplify the implementation and reduce the boilerplate introduced in this PR.

Instead of tracking isEmitting, hasNullListeners and replacing unsubscribed listeners with null to clean them up later, we can use a Cached-Copy approach similar to how RxJS Subject handles this.

This approach caches a copy of the listeners for iteration and invalidates it (sets to null) only when subscriptions change (subscribe/unsubscribe).

Proposed Simplification:

export class OutputEmitterRef<T> implements OutputRef<T> {
  private destroyed = false;
  private listeners: Array<(value: T) => void> | null = null;
  private currentListeners: Array<(value: T) => void> | null = null;
  private errorHandler = inject(ErrorHandler, {optional: true});

  // ...

  subscribe(callback: (value: T) => void): OutputRefSubscription {
    // ...
    this.currentListeners = null;
    (this.listeners ??= []).push(callback);

    return {
      unsubscribe: () => {
        const idx = this.listeners?.indexOf(callback) ?? -1;
        if (idx !== -1) {
          this.currentListeners = null;
          this.listeners!.splice(idx, 1);
        }
      },
    };
  }

  emit(value: T): void {
    // ...
    if (this.listeners === null) {
      return;
    }

    if (this.currentListeners === null) {
      this.currentListeners = Array.from(this.listeners);
    }

    const previousConsumer = setActiveConsumer(null);
    try {
      for (const listenerFn of this.currentListeners) {
        try {
          listenerFn(value);
        } catch (err: unknown) {
          this.errorHandler?.handleError(err);
        }
      }
    } finally {
      setActiveConsumer(previousConsumer);
    }
  }
}

Benefits:

  • Less Boilerplate: No isEmitting or hasNullListeners flags, no custom removeNullValues helper function.
  • Cleaner Hot Path: No null checks inside the emit loop.
  • Performance: 0 allocations/copies in the common case (emits without subscription changes). It only copies (Array.from) once when emitting after a subscription change.
  • Consistency: Matches the iteration behavior of RxJS Subject (which EventEmitter uses).

…steners

Fixes that when a listener unsubscribes from an `output` within its own callback, it was preventing subsequent listeners from running.

These changes fix the issue by not mutating the array while the emit loop is running, but replacing the listener with `null` and coming back later to remove it.

Fixes angular#69325.
@crisbeto crisbeto force-pushed the 69325/output-emitter-array branch from 0979294 to ca08aa5 Compare June 12, 2026 19:06
@crisbeto

Copy link
Copy Markdown
Member Author

Addressed the comments above. I'm not sure I agree with the agent suggestions, because I was explicitly trying to avoid copying the array in any case.

@atscott

atscott commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Addressed the comments above. I'm not sure I agree with the agent suggestions, because I was explicitly trying to avoid copying the array in any case.

SGTM. Agent also identified an edge case with nested emits and suggested a emittingDepth counter instead of the simple boolean though I think that's likely overkill

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: review The PR is still awaiting reviews from at least one requested reviewer area: core Issues related to the framework runtime target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants