How to re-trigger all pure pipes on all component tree in Angular 2
Asked Answered
M

5

27

I have pure pipe TranslatePipe that translates phrases using LocaleService that has locale$: Observable<string> current locale. I also have ChangeDetectionStrategy.OnPush enabled for all my components including AppComponent. Now, how can I reload whole application when someone changes language? (emits new value in locale$ observable).

Currently, I'm using location.reload() after user switches between languages. And that's annoying, because whole page is reloaded. How can I do this angular-way with pure pipe and OnPush detection strategy?

Mooneye answered 26/1, 2017 at 8:20 Comment(0)
M
15

Thanks to Günter Zöchbauer answer (see comments), I got it working.

As I understant, Angular's change detector works like this:

cd.detectChanges(); // Detects changes but doesn't update view.
cd.markForCheck();  // Marks view for check but doesn't detect changes.

So you need to use both in order to quickly rebuild whole component tree.

1. Template changes

In order to reload whole application we need to hide and show all component tree, therefore we need to wrap everything in app.component.html into ng-container:

<ng-container *ngIf="!reloading">
  <header></header>
  <main>
    <router-outlet></router-outlet>
  </main>
  <footer></footer>
</ng-container>

ng-container is better than div because it doesn't render any elements.

For async support, we can do something like this:

<ng-container *ngIf="!(reloading$ | async)"> ... </ng-container>

reloading: boolean and reloading$: Observable<boolean> here indicates that the component is currently being reloaded.

In the component I have LocaleService which has language$ observable. I will listen to changed language event and perform application reload action.

2. Sync example

export class AppComponent implements OnInit {
    reloading: boolean;

    constructor(
        private cd: ChangeDetectorRef,
        private locale: LocaleService) {

        this.reloading = false;
    }

    ngOnInit() {
        this.locale.language$.subscribe(_ => {
            this.reloading = true;
            this.cd.detectChanges();
            this.reloading = false;
            this.cd.detectChanges();
            this.cd.markForCheck();
        });
    }
}

3. Async example

export class AppComponent implements OnInit {
    reloading: BehaviorSubject<boolean>;

    get reloading$(): Observable<boolean> {
        return this.reloading.asObservable();
    }

    constructor(
        private cd: ChangeDetectorRef, // We still have to use it.
        private locale: LocaleService) {

        this.reloading = new BehaviorSubject<boolean>(false);
    }

    ngOnInit() {
        this.locale.language$.subscribe(_ => {
            this.reloading.next(true);
            this.cd.detectChanges();
            this.reloading.next(false);
            this.cd.detectChanges();
        });
    }
}

We don't have to cd.markForChanges() now but we still have to tell the detector to detect changes.

4. Router

Router doesn't work as expected. When reloading application in such fashion, router-outlet content will become empty. I did not resolve this problem yet, and going to the same route can be painful because this means that any changes user has made in forms, for example, will be altered and lost.

5. OnInit

You have to use the OnInit hook. If you try to call cd.detectChanges() inside of constructor, you will get an error because angular will not build component yet, but you will try to detect changes on it.

Now, you may think that I subscribe to another service in constructor, and my subscription will only fire after component is fully initialized. But the thing is - you don't know how the service works! If, for example, it just emits a value Observable.of('en') - you'll get an error because once you subscribe - first element emitted immediately while component is still not initialized.

My LocaleService has the very same issue: the subject behind observable is BehaviorSubject. BehaviorSubject is rxjs subject that emits default value immediately right after you subscribe. So once you write this.locale.language$.subscribe(...) - subscription immediately fires at least once, and only then you will wait for language change.

Mooneye answered 26/1, 2017 at 12:3 Comment(10)
You can use <ng-container *ngIf="!reloading">. It was added to be able to use the more common syntax, but still with the same behavior as the <template> element.Halftrack
Updated using <ng-container>.Mooneye
AFAIK cd.detectChanges(); runs change detection immediately and synchronously and cd.markForCheck(); runs change detection for this component on the next change detection cycle (somewhat later).Halftrack
When I tried to use cd.detectChanges() without cd.markForCheck() at the end - it just doesn't work. It doesn't work either with only cd.markForCheck().Mooneye
Might be related to the router. Perhaps ApplicationRef.tick() is a better way in this case to invoke change detection for the whole application. This might even fix 4.Halftrack
Your 5. is a bit weird. If this code is in the constructor, this.reloading.next() and the following code will be called at the same time as when you move it to ngOnInit. It will be called when this.locale.language$.subscribe(...) emits a event.Halftrack
ApplicationRef.tick() didn't do anything either) That's the first thing I tried when this issue arose. Anyway, I posted what works for me, now I'm trying to struggle with the Router. If I'll come to a more beautiful solution I'll update the unswer. Maybe angular should include some method like ApplicationRef.updateWholeComponentTreeWithAllRouters() ?Mooneye
Ok. Thans for the write-up anyway :)Halftrack
Also, I can't to force router-outlet to be updated. If I navigate to the same route or event to another route it's still empty (after my detecting changes).Mooneye
Hard to say. Could you try to reproduce in a Plunker? (as little code as possible, just as much to demonstrate the issue) Plunker provides a ready-to-use Angular2 TS template.Halftrack
F
31

Pure pipes are only triggered when the input value changes.

You could add an artificial additional parameter value that you modify

@Pipe({name: 'translate'})
export class TranslatePipe {
  transform(value:any, trigger:number) {
    ...
  }
}

and then use it like

<div>{{label | translate:dummyCounter}}</div>

Whenever dummyCounter is updated, the pipe is executed.

You can also pass the locale as additional parameter instead of the counter. I don't think using |async for a single pipe parameter will work, therefore this might a bit cumbersome (would need to be assigned to a field to be usable as pipe parameter)

Fredella answered 26/1, 2017 at 8:26 Comment(2)
Thanks, that's one way to solve this problem, but I have lots of strings in my templates and lots of code like {{'This is some text' | translate}}, so having an additional parameter that I'd have to put everywhere will hurt... Isn't there any way to reload whole app without reloading whole page?Mooneye
You can put an ngIf="expr" at the outermost element of your AppComponent, change expr to false, run ChangeDetectorRef.detectChanges(), set it back to true and run detectChanges() again. I think this is the simplest way. This way everything will be removed and readded. You might need to navigate to the current route again (not sure how the router reacts to this at all though, but I think it's easy enough to just try it)Halftrack
W
22

BEST PERFORMANCE SOLUTION:

I figured out a solution for this. I hate to call it a solution, but it works.

I was having the same issue with and orderBy pipe. I tried all the solutions here but the performance impact was terrible.

I simply added an addtional argument to my pipe

let i of someArray | groupBy:'someField':updated" 
<!--updated is updated after performing some function-->

then anytime I perform an update to the array I simply to

updateArray(){
    //this can be a service call or add, update or delete item in the array
      .then.....put this is in the callback:

    this.updated = new Date(); //this will update the pipe forcing it to re-render.
}

This forces my orderBy pipe to do a transform again. And the performance is a lot better.

Watch answered 14/2, 2018 at 0:52 Comment(1)
You don't need to send a new Date. Just send a new empty object {}. Angular checks the reference, so that is enough and waaaay less overhead, specifically when you label your solution as "performant".Aloha
M
15

Thanks to Günter Zöchbauer answer (see comments), I got it working.

As I understant, Angular's change detector works like this:

cd.detectChanges(); // Detects changes but doesn't update view.
cd.markForCheck();  // Marks view for check but doesn't detect changes.

So you need to use both in order to quickly rebuild whole component tree.

1. Template changes

In order to reload whole application we need to hide and show all component tree, therefore we need to wrap everything in app.component.html into ng-container:

<ng-container *ngIf="!reloading">
  <header></header>
  <main>
    <router-outlet></router-outlet>
  </main>
  <footer></footer>
</ng-container>

ng-container is better than div because it doesn't render any elements.

For async support, we can do something like this:

<ng-container *ngIf="!(reloading$ | async)"> ... </ng-container>

reloading: boolean and reloading$: Observable<boolean> here indicates that the component is currently being reloaded.

In the component I have LocaleService which has language$ observable. I will listen to changed language event and perform application reload action.

2. Sync example

export class AppComponent implements OnInit {
    reloading: boolean;

    constructor(
        private cd: ChangeDetectorRef,
        private locale: LocaleService) {

        this.reloading = false;
    }

    ngOnInit() {
        this.locale.language$.subscribe(_ => {
            this.reloading = true;
            this.cd.detectChanges();
            this.reloading = false;
            this.cd.detectChanges();
            this.cd.markForCheck();
        });
    }
}

3. Async example

export class AppComponent implements OnInit {
    reloading: BehaviorSubject<boolean>;

    get reloading$(): Observable<boolean> {
        return this.reloading.asObservable();
    }

    constructor(
        private cd: ChangeDetectorRef, // We still have to use it.
        private locale: LocaleService) {

        this.reloading = new BehaviorSubject<boolean>(false);
    }

    ngOnInit() {
        this.locale.language$.subscribe(_ => {
            this.reloading.next(true);
            this.cd.detectChanges();
            this.reloading.next(false);
            this.cd.detectChanges();
        });
    }
}

We don't have to cd.markForChanges() now but we still have to tell the detector to detect changes.

4. Router

Router doesn't work as expected. When reloading application in such fashion, router-outlet content will become empty. I did not resolve this problem yet, and going to the same route can be painful because this means that any changes user has made in forms, for example, will be altered and lost.

5. OnInit

You have to use the OnInit hook. If you try to call cd.detectChanges() inside of constructor, you will get an error because angular will not build component yet, but you will try to detect changes on it.

Now, you may think that I subscribe to another service in constructor, and my subscription will only fire after component is fully initialized. But the thing is - you don't know how the service works! If, for example, it just emits a value Observable.of('en') - you'll get an error because once you subscribe - first element emitted immediately while component is still not initialized.

My LocaleService has the very same issue: the subject behind observable is BehaviorSubject. BehaviorSubject is rxjs subject that emits default value immediately right after you subscribe. So once you write this.locale.language$.subscribe(...) - subscription immediately fires at least once, and only then you will wait for language change.

Mooneye answered 26/1, 2017 at 12:3 Comment(10)
You can use <ng-container *ngIf="!reloading">. It was added to be able to use the more common syntax, but still with the same behavior as the <template> element.Halftrack
Updated using <ng-container>.Mooneye
AFAIK cd.detectChanges(); runs change detection immediately and synchronously and cd.markForCheck(); runs change detection for this component on the next change detection cycle (somewhat later).Halftrack
When I tried to use cd.detectChanges() without cd.markForCheck() at the end - it just doesn't work. It doesn't work either with only cd.markForCheck().Mooneye
Might be related to the router. Perhaps ApplicationRef.tick() is a better way in this case to invoke change detection for the whole application. This might even fix 4.Halftrack
Your 5. is a bit weird. If this code is in the constructor, this.reloading.next() and the following code will be called at the same time as when you move it to ngOnInit. It will be called when this.locale.language$.subscribe(...) emits a event.Halftrack
ApplicationRef.tick() didn't do anything either) That's the first thing I tried when this issue arose. Anyway, I posted what works for me, now I'm trying to struggle with the Router. If I'll come to a more beautiful solution I'll update the unswer. Maybe angular should include some method like ApplicationRef.updateWholeComponentTreeWithAllRouters() ?Mooneye
Ok. Thans for the write-up anyway :)Halftrack
Also, I can't to force router-outlet to be updated. If I navigate to the same route or event to another route it's still empty (after my detecting changes).Mooneye
Hard to say. Could you try to reproduce in a Plunker? (as little code as possible, just as much to demonstrate the issue) Plunker provides a ready-to-use Angular2 TS template.Halftrack
D
14

Just set the property pure to false

@Pipe({
  name: 'callback',
  pure: false
})
Daman answered 9/2, 2018 at 1:28 Comment(4)
"Angular executes an impure pipe during every component change detection cycle. An impure pipe is called often, as often as every keystroke or mouse-move. With that concern in mind, implement an impure pipe with great care. An expensive, long-running pipe could destroy the user experience."Kurt
@Kurt checking translation is just reading from hashmap. can it affect performance so bad?Pissarro
It depends on the website you're building, but if it's a huge website, with lots of bindings... yes it can be one more thing to slow down the experience. (my opinion, from experience, not actually measured!)Kurt
The title of the question is "How to re-trigger all pure pipes on all component tree in Angular 2". If your "answer" is "make it not pure" then you failed to understand the question. All we devs are aware of impure pipes are re-triggered a lot of times, that's not the question here.Aloha
I
3

You can also create your own unpure pipe to track external changes. Check the sources of native Async Pipe to get the main idea.

All you need is to call ChangeDetectorRef.markForCheck(); inside of your unpure pipe every time your Observable return new locale string. My solution:

@Pipe({
  name: 'translate',
  pure: false
})
export class TranslatePipe implements OnDestroy, PipeTransform {

  private subscription: Subscription;
  private lastInput: string;
  private lastOutput: string;

  constructor(private readonly globalizationService: GlobalizationService,
              private readonly changeDetectorRef: ChangeDetectorRef) {
    this.subscription = this.globalizationService.currentLocale // <- Observable of your current locale
      .subscribe(() => {
        this.lastOutput = this.globalizationService.translateSync(this.lastInput); // sync translate function, will return string
        this.changeDetectorRef.markForCheck();
      });
  }

  ngOnDestroy(): void {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
    this.subscription = void 0;
    this.lastInput = void 0;
    this.lastOutput = void 0;
  }

  transform(id: string): string { // this function will be called VERY VERY often for unpure pipe. Be careful.
    if (this.lastInput !== id) {
      this.lastOutput = this.globalizationService.translateSync(id);
    }
    this.lastInput = id;
    return this.lastOutput;
  }
}

Or you even can incapsulate AsyncPipe inside your pipe (not a good solution, just for example):

@Pipe({
  name: 'translate',
  pure: false
})
export class TranslatePipe implements OnDestroy, PipeTransform {

  private asyncPipe: AsyncPipe;
  private observable: Observable<string>;
  private lastValue: string;

  constructor(private readonly globalizationService: GlobalizationService,
              private readonly changeDetectorRef: ChangeDetectorRef) {
    this.asyncPipe = new AsyncPipe(changeDetectorRef);
  }

  ngOnDestroy(): void {
    this.asyncPipe.ngOnDestroy();
    this.lastValue = void 0;
    if (this.observable) {
      this.observable.unsubscribe();
    }
    this.observable = void 0;
    this.asyncPipe = void 0;
  }

  transform(id: string): string {
    if (this.lastValue !== id || !this.observable) {
      this.observable = this.globalizationService.translateObservable(id); // this function returns Observable
    }
    this.lastValue = id;

    return this.asyncPipe.transform(this.observable);
  }

}
Insouciant answered 11/7, 2018 at 5:32 Comment(7)
He specifically asked for a pure pipe.Aronson
Bryan, I know. But Async Pipe solution is good enough to create translation pipes. At least, it works on my projects very well. No performance issues were noticed.Insouciant
Isn't markForCheck meaningless in a non-pure pipe? Or even any pipe?Electrostatics
@cambunctious, if your pipe depends on something that is not a parameter of this pipe (for example, the application locale from localization service), then if that parameter changes, you need to force the pipe to call the transform() method again. Even if your ChangeDetectionStrategy is Default. ChangeDetectorRef.markForCheck() will do that. It will mark the current view to re-check changes, and that will call the transform() function.Insouciant
Okay, I see. This is a good solution since it prevents the component from having to use ChangeDetectorRef. AsyncPipe is a good related example since it is also implemented using ChangeDetectorRef, but you certainly don't need to use it. Also just to note, an impure pipe is absolutely necessary if you want a pipe to change its output without changing the input. As long as the transform method is inexpensive, it's okay, in the same way that it's okay to use default change detection and have angular access your component often.Electrostatics
Harry, an old alligator article actually recommended using markForCheck at the end of the subscribe block as you pointed out. However, this could be inside of ngOnInit instead of the constructor.Photo
What you wrote has nothing to do with the question asked. The question is about PURE pipes.Aloha

© 2022 - 2024 — McMap. All rights reserved.