Change component input from ngOnChanges, while using OnPush strategy
Asked Answered
M

1

6

I'm having a problem with an Angular 6 application:

The problem

Let's say I have 2 components: parent and child.

the child has 2 inputs. While 1 input changes, inside the ngOnChanges() the child component emit somthing to the parent component.

Then the parent component changes the second input for the child component. I expect the change detection of the child component to get called once again- BUT IT'S NOT. The view of the child component shows old value of the second input.


Defenitions

the application has the following defenitions:

  1. All components use change detection strategy OnPush. that includes the root component.
  2. I'm using ngrx store. call markForCheck() or detectChanges() inside the reducer is not an option.
  3. The data goes down to the child component using async pipe, because it comes from the store using selector (observable).
  4. I can't cause the change of the second input earlier (at the parent component for example)- it has to happen after the child component recives a change of the first input.

To illustrate the case, I've created a simple demo project. There I don't use store, so I don't have any selectors.. instead I'm using rxjs Subject for the use of async pipe.

Here it is:

https://angular-ccejq4.stackblitz.io

Take a look the the console to understand what happens and when.


Bad solutions

What i've already tried:

  1. changing the change detection strategy at the root component to default. It works but this is not a good solution since it causing performance issues.
  2. call markForCheck() before and after the emit(!) - NOT WORKING.
  3. call detectchanges() before and after the emit(!) - NOT WORKING.
  4. subscribe to the selector at the parent component instead of using async pipe - NOT WORKING.
  5. call the emit from within a setTimeout - IT WORKS. but is there a better solution? a more Angular-driven one. because this solution feels more like a workaround.
  6. use ngAfterViewInit/ngAfterViewChecked and emit, call markForCheck() and detectChanges() from there - NOT WORKING.

I found that the new value arrives to the async pipe. the async pipe then calls markForCheck(). but it doesn't call detectChanges().. so a change detection cycle won't run. the view will change properly only at the next cycle of change detection- as you can see on my demo app.


Any ideas?

thanks!

Mettlesome answered 27/2, 2020 at 8:0 Comment(1)
I've updated my answer :D I thought about the proper way to handle your issueJenson
J
7

Let me first say, great question with detail and well explained. You have indeed hit a caveat of the angular change detection.

From within ngOnChanges there is no direct way to trigger another change detection. Otherwise you will have the possibility to hit a loop. Also performance wise they do not allow this. You need to find a way to get into the next execution cycle of JS

The setTimeout is not a bad option to overcome this. You can either put this around the emit:

setTimeout(() => this.secondEmitter.emit(this.first));

or you can put it around a markForCheck call:

setTimeout(() => this.cdRef.markForCheck());

But I agree with you that it feels a bit hacky, but sometimes you just need to cope with it. If you really cannot, there is another way. Your BehaviorSubject is by nature a synchronous observable. You can however make the async subscription in your template to use the asyncScheduler:

private second = new BehaviorSubject(0);

readonly second$ = this.second.asObservable().pipe(
  observeOn(asyncScheduler)
)

constructor() {
  this.second.next(0);
  this.second$.subscribe((lastval) => {
    console.log(`second subscription update to ${lastval}`);
  });
}

From the last option I've made a fork of your stack

This will make any subscription to it receive the message after the next event loop cycle.


Update

Coming back to this, a better solution would be to use the async option on the creation of the EventEmitter.

In that case you can use .emit on the second observable:

@Output() firstEmitter:EventEmitter<number> = new EventEmitter();

// notice the 'true'
@Output() secondEmitter:EventEmitter<number> = new EventEmitter(true);

ngOnChanges(changes:any) {
  if (changes.first) {
    this.secondEmitter.emit(this.first);
  }
}

working example

Jenson answered 27/2, 2020 at 8:31 Comment(1)
Async EventEmitter is perfect !Tiffany

© 2022 - 2024 — McMap. All rights reserved.