Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Element doesn't get destroyed on router navigation after manipulating DOM

Tags:

angular

I have a directive that needs to move the element on which it is applied to the body of the document:

@Directive({
  selector: '[myDirective]'
})
export class MyDirective implements AfterViewInit {
  constructor(private elem: ElementRef,
    private renderer: Renderer2) {
  }

  ngAfterViewInit() {
    // Move element to body
    this.renderer.appendChild(document.body, this.elem.nativeElement);
  }
}

Now I use this directive in an element with an ngIf that I toggle based on some condition:

<div myDirective *ngIf="visible">Test</div>
<button (click)="visible = !visible">Toggle</button>

When it runs, the div will show after the button as expected, because it was appended to the body. The toggle button still works at this point, and I can show/hide the div.

The problem comes when I introduce routes:

<a routerLink="/route/1">Link</a>
<div myDirective *ngIf="visible">Test</div>
<button (click)="visible = !visible">Toggle</button>

Now, if I show the div, and navigate to another route, the div should get destroyed, but it's still visible! If I don't move the div to the body then it behaves as expected.

First, I tried removing the element in ngOnDestroy but this doesn't work with animations, because it deletes the element without playing its :leave animation.

My current workaround is to inject the router into the directive and subscribe to the first event that comes through to restore the div to its original container:

this.routerSubscription = router.events.first().subscribe(() => {
  this.renderer.appendChild(this.parentNode, this.elem.nativeElement);
});

While this works, it is not great, as now the directive has a dependency in the router when it should really know nothing about it.

How can improve on this hack to make sure the element gets destroyed properly?

DEMO: https://stackblitz.com/edit/angular-qximjw?file=app%2Fapp.component.ts

To reproduce the issue:

  1. Make sure the URL it's at the root and not within a route
  2. Click the toggle button
  3. Click the link to navigate to the route
  4. Watch the div NOT disappear
like image 756
elclanrs Avatar asked Feb 01 '18 01:02

elclanrs


2 Answers

You can add code to remove the element at ngOnDestroy:

ngOnDestroy() {
  if (this.routerSubscription) {
    this.routerSubscription.unsubscribe();
  }
  document.body.removeChild(this.elem.nativeElement);
}

But be aware that when you navigate to the same route, angular won't destroy it (this means ngOnDestroy won't trigger). You can try by add another route and navigate to it to confirm.

See demo.

like image 138
Pengyy Avatar answered Nov 07 '22 10:11

Pengyy


Well i think it works as intended. If you use your browser inspect mode and click on the RouterLink button you will notice that the HomeComponent is getting re-rendered because you are serving the same Component on both of your Router Options.

const routes: Routes = [
  {
    path: '',
    component: HomeComponent
  },
  {
    path: 'route/:id',
    component: HomeComponent
  }
];

Therefore, if your Directive is appended and not removed from the DOM, when the HomeComponent gets rendered again, a new instance of that component and also your Directive will be created thus, the application will append an new <div> on your body while clicking the (new) Button. To make it more clear i suggest you to replace the HomeComponent code with the one below and check out your JavaScript Console.

@Component({
  selector: 'home-component',
  template: `
  <a routerLink="/route/1">Link</a>
  <div myDirective *ngIf="visible" @transition>Test</div>
  <button (click)="visible = !visible">Toggle</button>
  `,
  animations: [
    trigger('transition', [
      transition(':enter', [
        style({ opacity: 0 }),
        animate('.35s ease', style({ opacity: '*' }))
      ]),
      transition(':leave', [
        style({ opacity: '*' }),
        animate('.35s ease', style({ opacity: 0 }))
      ])
    ])
  ]
})
export class HomeComponent {
  visible = false;
  constructor() {
    console.log('New instance')
  }
}

A workaround that i can think of is putting the Toggle button outside the router-outlet boundaries inside a Component.

@Component({
    selector: 'app-toggle-button',
    template: `
    <div myDirective *ngIf="visible" @transition>Test</div>
    <button (click)="visible = !visible">Toggle</button>
    `,
    animations: [
      trigger('transition', [
        transition(':enter', [
          style({ opacity: 0 }),
          animate('.35s ease', style({ opacity: '*' }))
        ]),
        transition(':leave', [
          style({ opacity: '*' }),
          animate('.35s ease', style({ opacity: 0 }))
        ])
      ])
    ]
  })
  export class ToggleComponent {
    visible = false;
  }

And after that you can add it on your app.component.html file:

<app-toggle-button></app-toggle-button>
<router-outlet></router-outlet>

In that case you will always have A SINGLE instance of the ToggleComponent even if you navigate through multiple Routes.

Let me know if that helped you, cheers.

like image 3
Giannis Faropoulos Avatar answered Nov 07 '22 08:11

Giannis Faropoulos