angular: Unable to overwrite ngDestroy inside the component

🐞 bug report

Affected Package

The issue is caused by package @angular/....

Is this a regression?

Yes, the previous version in which this bug was not present was: 8.2

Description

Please take a look here for a source code: https://github.com/xtephan/ng-destroy-test/blob/master/src/app/destroyable/destroyable.component.ts

When the DestroyableComponent is being destroyed, the ngOnDestroy method as defined on line 16 is called, instead of as defined on line 11.

If inspecting the component using the devtools, ngOnDestroy method points to being located on line 11: Screenshot 2020-04-23 at 09 38 18

🔬 Minimal Reproduction

https://github.com/xtephan/ng-destroy-test

The master branch contains ng9, where bug can be seen present.

The ng8 branch contains ng8, where the bug was not present.

Run the app with ng serve and access http://localhost:4200. In the webpage, press the toggle button to destroy the inner component.

The expected result would to see this.ngOnDestroy called outputed in the console. Actual behaviour is that DestroyableComponent.ngOnDestroy called is being outputed.

🔥 Exception or Error

None

🌍 Your Environment

Angular Version:


     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    

Angular CLI: 9.1.3
Node: 12.4.0
OS: darwin x64

Angular: 9.1.3
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.901.3
@angular-devkit/build-angular     0.901.3
@angular-devkit/build-optimizer   0.901.3
@angular-devkit/build-webpack     0.901.3
@angular-devkit/core              9.1.3
@angular-devkit/schematics        9.1.3
@ngtools/webpack                  9.1.3
@schematics/angular               9.1.3
@schematics/update                0.901.3
rxjs                              6.5.5
typescript                        3.8.3
webpack                           4.42.0

Anything else relevant?

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 10
  • Comments: 15 (6 by maintainers)

Most upvoted comments

I work with @xtephan, on the same project, so i can attempt to answer that.

We have a custom subsink, that overwrites the ngDestroy method to allow us to cleanup subscriptions when a component is destroyed. The subsink works by assigning to a property directly on the object, and keeping a reference to the old method, so we can call the onDestroy. So in relation to what is said on the linked issues, specifically here: https://github.com/angular/angular/issues/7270#issuecomment-201137617, the onDestroy method is there in the original class definition, as can also be seen in the sample provided.

Subsink code:

import { Unsubscribable } from 'rxjs';
import { OnDestroy } from '@angular/core';
import { onNgOnDestroy } from './hooks';

export class SubSink {
  private _subs: Unsubscribable[] = [];

  /**
   * @param component - The component to link the unsubscription to
   */
  public constructor(component: OnDestroy) {
    onNgOnDestroy(component, () => this.unsubscribe());
  }

  /**
   * Adds a single subscription to the sink
   * @param subscription
   */
  public set sink(subscription: Unsubscribable) {
    this._subs.push(subscription);
  }

  /**
   * Adds some subscriptions to the sink
   * @param subcriptions
   */
  public add(...subcriptions: Unsubscribable[]) {
    this._subs.push(...subcriptions);
  }

  public unsubscribe() {
    for (const sub of this._subs) {
      sub.unsubscribe();
    }
    this._subs = [];
  }
}

hooks.ts:

import { OnDestroy } from '@angular/core';

export function onNgOnDestroy(destroyable: OnDestroy, cb: () => void) {
  const original = destroyable.ngOnDestroy;
  destroyable.ngOnDestroy = () => {
    cb();
    original.apply(destroyable);
    destroyable.ngOnDestroy = original;
  };
}

The way the hook is implemented, we can layer multiple on destroy hooks, without them getting in the way of each other, since they will all be called.

The subsink is then used in a component like this:

export class MyComponent implements OnInit, OnDestroy {
   private sink = new SubSink(this);

  ngOnInit() {
     const myObs = /* Something that gets an observable */
     this.sink.sink = myObs.subscribe();
  }

  ngOnDestroy() {
   // Can be left empty if no other teardown is required
}
}

An alternative we have discussed internally as a solution for this specific regression, is making the SubSink an injectable service (With a unique instance for each component instance), and then relying on ngOnDestroy being called on the service. This however then requires that we explicitly pass the SubSink in the provider array of every single component we have. To avoid forgetting to do that, we will need to write a custom lint rule to check that. All in all, quite a bit of work to get around Angular not “observing” the normal rules of how “methods” works in javascript, and instead relying on the method only being defined on the prototype.

@juanmendes Here you go: https://gist.github.com/zlepper/df282c06bafe46342d5b43f4e049726a It needs to be compiled and such from the TS version, but i figure you can handle that part? 😃

@flash-me I’m not sure how your example matches the SubSink example, you are being forced to call unsubscribe yourself. Their example automatically unsubscribes from ngOnDestroy? Or are you just suggesting a different approach to implement the SubSink?

Additionally adding subscriptions to other subscriptions also causes those subscriptions to be cancelled, if the first subscription completes (At least it did last i tried).

@juanmendes We ended up using a component scoped service instead:

@Injectable()
export class SubSinkService implements OnDestroy {
  private _subs: Unsubscribable[] = [];

  /**
   * Adds a single subscription to the sink
   * @param subscription
   */
  public set sink(subscription: Unsubscribable) {
    this._subs.push(subscription);
  }

  /**
   * Adds some subscriptions to the sink
   * @param subcriptions
   */
  public add(...subcriptions: Unsubscribable[]) {
    this._subs.push(...subcriptions);
  }

  public unsubscribe() {
    for (const sub of this._subs) {
      sub.unsubscribe();
    }
    this._subs = [];
  }

  ngOnDestroy(): void {
    this.unsubscribe();
  }
}

Turns out Angular calls the OnDestroy method on services if they are scoped. So we add the service to our component providers if we need it, then dependency inject it. Then we additionally have a ts-lint rule to make sure you actually remember to add it to providers, and doesn’t accidently use the parent’s subsink. It’s a bit more involved, but we still get the behavior we want, even though it’s nowhere near as nice as it used to be.