Joseph Zabinski
Joseph Zabinski

Reputation: 850

Angular: *ngIf failing using concatMap with async pipe

If I have an Observable releasing values over time:

values$ = from([1, 2, 3, 'done'])
  .pipe(
    concatMap((x) => of(x).pipe(delay(1000)))
  );

And I have a function returning access to that Observable:

getOutputs(): Observable<'done' | number> {
    return this.values$;
  }

And subscribe to the Observable through a function in the template using *ngIf and async:

<div *ngIf="getOutputs() | async as val">
  <hello name="{{ val }}"></hello>
</div>

The behavior is expected: the browser shows 'Hello 1!', 'Hello 2!', 'Hello 3!', 'Hello done!', with an interval for each of about a second.

If, instead, I store the latest value in a BehaviorSubject and cycle all of the values through that BehaviorSubject in ngOnInit:

outputs$ = new BehaviorSubject<number | 'done' | null>(null);
ngOnInit(): void {
    this.subscriptions.add(
      from<[number, number, number, 'done']>([
        1,
        2,
        3,
        'done',
      ]).subscribe((val) => this.outputs$.next(val))
    );
  }

The behavior is of course different: the values are all sent to the BehaviorSubject, and outputs$.value becomes 'done' very quickly. So anything coming along later and subscribing would only get 'done'. Also expected.

If I change getOutputs() to use this.outputs$ instead, I just get 'Hello done!':

getOutputs(): Observable<null | 'done' | number> {
    return this.outputs$;
  }

But if I add the same concatMap used earlier, like this:

getOutputs(): Observable<null | 'done' | number> {
    return this.outputs$
    .pipe(
      concatMap((x) => of(x).pipe(delay(1000)))
    );
  }

'done' gets sent over and over, once a second (which can be seen through tap(console.log)), but the template shows nothing. This is unexpected: I would think that the HTML would show 'Hello done!'.

Why is this happening?

See this Stackblitz.

Upvotes: 0

Views: 141

Answers (1)

ccjmne
ccjmne

Reputation: 9618

TL;DR

This is caused by how Angular handles change detection.

Angular will regularly check that your view is up-to-date with the data in your model, and is in fact continuously calling your getOuputs() method, once every second!


Angular's Change Detection in a nutshell

Consider your app.component.html template:

<div *ngIf="getOutputs() | async as val">
  <hello name="{{ val }}"></hello>
</div>

Here, Angular will regularly re-evaluate getOutputs() | async, a "couple of times", until your application is in a stable state.

However, for each evaluation, you are returning a new, unique Observable, because you create that new, unique Observable in your getOutputs method:

public getOutputs(): Observable</* ... */> {
  return this.outputs$.pipe(
     concatMap(x => of(x).pipe(delay(1000))),
  ); // it's not `this.outputs$`, it's a **new Observable**
}

Therefore, if you where to create another member like that:

 export class AppComponent implements OnInit, OnDestroy {
   private subscriptions = new Subscription();
   private outputs$ = new BehaviorSubject</* ... */>(null);
+  private actualOutputs$ = this.outputs$.pipe(
+    concatMap(x => of(x).pipe(delay(1000))),
+  );

   public getOutputs(): Observable</* ... */> {
+    return this.actualOutputs$;
-    return this.outputs$.pipe(
-       concatMap(x => of(x).pipe(delay(1000))),
-    );
   }
 }

(welp, StackOverflow doesn't support diff syntax highlighting, sorry about that...)

... then your application would behave exactly as you expect!


Some more exploration

But then why does removing the delay, yet yielding a different Observable every time also works?

Let's consider the alternative implementation for getOutputs below:

public getOutputs(): Observable</* ... */> {
  return this.outputs$.pipe(
    concatMap(x => of(x)/* .pipe(delay(1000)) */), // no more delay here
  ); // that's a **new** Observable every time.
}

It's because I (purposely 🙂) overlooked something earlier:
Angular isn't simply re-evaluating getOutputs() until your component is stable, but actually getOutputs() | async, which translates roughly as async(getOutput()) in a pure TypeScript world.

What is happening here is also somewhat simple:
Contrary to the previous case, where the AsyncPipe, when having to wait for a delayed Observble, won't be definitely stable; that AsyncPipe yields a value immediately and yields the same ('done') every time; Angular considers your component "stable" and proceeds with updating the view.

If you debug the code attentively (or drop a console.log at the top of getOutputs()'s body), you can notice that getOutputs() is then still called 4 times in rapid succession, one for each evaluation of async(getOutputs()).

Switching to a more conservative changeDetection strategy for your component as follows:

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush, // different strategy here
})
export class AppComponent implements OnInit, OnDestroy {

... would allow getOutputs() to only be executed once in that case!

Upvotes: 1

Related Questions