1

I have a problem with the async-pipe in combination with an Observable which gets it's first value in OnInit. Must be a timing issue about the point in time when OnInit happens and the one when the template gets rendered and thus the Observable get subscribed.

Consider this component:

export class AppComponent implements OnInit {

    subjectA$: Subject<{name:string}>;
    subjectB$: Subject<{name:string}>;

    constructor(
        protected http: HttpClient
    ) {
    }

    ngOnInit() {
        this.subjectA$ = new Subject<{name: string}>();
        this.subjectA$.next({name: "A"});

        this.subjectB$ = new Subject<{name: string}>();
        setTimeout(() => {
          this.subjectB$.next({name: "B"});
        }, 0);
    }

}

and the template:

<p *ngIf='subjectA$ | async as subjectA; else: nosubjectA'>
  subjectA: {{subjectA.name}}
</p>

<ng-template #nosubjectA>
  <p>no subjectA</p>
</ng-template>

<p *ngIf='subjectB$ | async as subjectB; else: nosubjectB'>
  subjectB: {{subjectB.name}}
</p>

<ng-template #nosubjectB>
  <p>no subjectB</p>
</ng-template>

This results in

no subjectA

subjectB: B 

That means: Even if subjectA$ got a value in onInit, the view is not updated. If I wrap around the creation of the first value in a setTimeout as you can see with subjectB$, it works and I see the value. Although this is a solution I am wondering why does this this happen and is there a better solution?

One solution I already found would be using BehaviorSubject instead an provide the first value as initial value:


        this.subjectC$ = new BehaviorSubject<{name: string}>({name: "C"});

leads to subjectC: C with analogous template for subjectC.

Try all on StackBlitz.

My real observable is no Subject at all but the result of a combineLatest-call of different stuff, from which only one is (and have to unfortunately since it is using a value from an @Input()-annotation) a Subject, and manually pushed with next in OnInit as in the example. The rest comes from http et al. Most likely I could wrap the combined result in a BehaviourSubject but it seems ugly and dangerous to me, so it's even worse then the setTimeout approach. But I bet someone can help me out and find a real useful solution. In addition, I would prefer to avoid BehaviorSubject, to prevent developers from being tempted to use getValue.

See on Stackblitz

Paflow
  • 2,030
  • 3
  • 30
  • 50
  • had a similar problem recently (specifically converting the @Input into an observable) and i was able to solve by using `shareReplay(1)` (or use a `ReplaySubject` with a buffer of 1) for the observable - the problem with doing .next inside `ngOnInit` seems like it runs first before the template actually subscribes to the observables. Moving things to ngAfterViewInit (or ngAfterContentInit) doesn't really solve the problem too as change detection will complain. So all I was left with is to replay the last value, store it in a `BehaviorSubject`, or use `setTimeout` as well. – arvil Jul 24 '20 at 19:05
  • Okay that means I don't should be too optimistic for trying out other hooks than `onInit`... How can the internet be full of advices to use `async` instead of manual subscription but don't have a suitable solution for this? – Paflow Jul 27 '20 at 07:07

2 Answers2

3

A quick fix would be to use ReplaySubject with buffer 1 instead of BehaviorSubject. You do not have to provide a default value and it neither has the getValue() function nor value getter. And yet it buffers (or holds) the last emitted value and emits it immediately upon new subscriptions.

Try the following

ngOnInit() {
  this.subjectA$ = new ReplaySubject<{name: string}>(1);
  this.subjectA$.next({name: "A"});

  this.subjectB$ = new ReplaySubject<{name: string}>(1);
  this.subjectB$.next({name: "B"});

  this.subjectC$ = combineLatest([this.subjectA$, this.subjectB$]).pipe(
    map((things: [{name:string}, {name:string}]): {name:string} => {
      return {name: things.map(thing => thing.name).join('|')}
    })
  );
}

I've modified your Stackblitz.

ruth
  • 29,535
  • 4
  • 30
  • 57
  • Thank you, without question a better approach then `BehaviorSubject`. But still still feels more like a workaround than a solution. The underlying problem comes clearly from the order of `ngInit` and subscription of `async`, and the way angular handles the next call when made asynchronous (but still immediately since `setTimeout` is called with `0`). I guess I one understood these things well, one could come up with a solution directly approaching to than (and use another hook for example or whatever). – Paflow Jul 27 '20 at 07:05
  • 1
    @Paflow: If you wish to understand why it works if you call the `setTimeout` with `0`, you could see [here](https://stackoverflow.com/a/779785/6513921). – ruth Jul 27 '20 at 11:47
1

After the comment I made, I really couldn't help but think that there must be a better way - and finally thought of something that worked!

I just modified your stackblitz a bit.

private valueA = "A";
private valueB = "B";

subjectA$ = of({ name: this.valueA });
subjectB$ = of({ name: this.valueB });
subjectC$ = combineLatest([this.subjectA$, this.subjectB$])
          .pipe(
            map((things: [{name:string}, {name:string}]): {name:string} => {return {name: things.map(x => x.name).join('|')}})
          );

This way, we can even discard the ngOnInit hook, and everything works at it should!

arvil
  • 433
  • 4
  • 10
  • At a first glance this seemed to be the perfect solution, clean, simple and not feeling like a workaround. And for many similar cases it might be it. But as I wrote, I need a value from an `@Input()`-annotation, to calculate the (first) content of one of the combines Observables - which is not present at construction time, but in `onInit`. I thought, the solution would be to initialize the Observables at construction time, and give them their first value in `onInit` - but no, you would still need the `setTimeout`-hack. See: https://stackblitz.com/edit/nginit-async-problem-3 – Paflow Jul 28 '20 at 15:40
  • 2
    Apologies... but I really think I've got it this time. Since right now, `of` creates a hot observable and since the value of the input is really not available initially, the value would not have been available. So I think what we really need is a cold observable. I updated your stackblitz to really show what you need - with a separate component and the 2 `@Inputs` a and b. https://stackblitz.com/edit/nginit-async-problem-3-y6qxuo – arvil Jul 28 '20 at 17:08
  • I am stunned that this works, and when `@Input` actually get populated, but nevertheless I think this is a suitable solution. – Paflow Jul 29 '20 at 09:28