diff --git a/apps/integration/src/app/inheritance/inheritance.component.html b/apps/integration/src/app/inheritance/inheritance.component.html
index 16526c6..e374689 100644
--- a/apps/integration/src/app/inheritance/inheritance.component.html
+++ b/apps/integration/src/app/inheritance/inheritance.component.html
@@ -11,13 +11,20 @@
with `@UntilDestroy()` but directive is not
- `Issue97Component` extends the from `Issue97Directive`. Directive is decorated with
+ `Issue97Component` extends from `Issue97Directive`. Directive is decorated with
`@UntilDestroy()` but component is not
+
+ `Issue175Component` extends from the `Issue175Directive`. Directive is decorated with
+ `@UntilDestroy()` and the `Issue175Component` implements its own `ngOnDestroy()`
+ method. The `Issue175Directive.ngOnDestroy()` will not be called, because
+ `Issue175Component.ngOnDestroy()` does not call `super.ngOnDestroy()`.
+
+
Issue#61: {{ issue61StatusText$ | async }}
@@ -26,6 +33,10 @@
Issue#97: {{ issue97StatusText$ | async }}
+
+
+ Issue#175: {{ issue175StatusText$ | async }}
+
@@ -35,4 +46,11 @@
Toggle issue#97
+
+ Toggle issue#175
+
diff --git a/apps/integration/src/app/inheritance/inheritance.component.ts b/apps/integration/src/app/inheritance/inheritance.component.ts
index 3f37f5c..ee72359 100644
--- a/apps/integration/src/app/inheritance/inheritance.component.ts
+++ b/apps/integration/src/app/inheritance/inheritance.component.ts
@@ -7,15 +7,16 @@ import { NotificationClass, NotificationText } from '../enums/notification.enum'
@Component({
selector: 'app-inheritance',
templateUrl: './inheritance.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InheritanceComponent {
issue61Shown = true;
issue97Shown = true;
+ issue175Shown = true;
issue61Status$ = new BehaviorSubject({
directiveUnsubscribed: false,
- componentUnsubscribed: false
+ componentUnsubscribed: false,
});
issue61StatusClass$ = this.issue61Status$.pipe(
@@ -39,7 +40,7 @@ export class InheritanceComponent {
);
issue97Status$ = new BehaviorSubject({
- componentUnsubscribed: false
+ componentUnsubscribed: false,
});
issue97StatusClass$ = this.issue97Status$.pipe(
@@ -54,6 +55,20 @@ export class InheritanceComponent {
)
);
+ issue175Status$ = new BehaviorSubject({ componentUnsubscribed: false });
+
+ issue175StatusClass$ = this.issue175Status$.pipe(
+ map(({ componentUnsubscribed }) =>
+ componentUnsubscribed ? NotificationClass.Success : NotificationClass.Danger
+ )
+ );
+
+ issue175StatusText$ = this.issue175Status$.pipe(
+ map(({ componentUnsubscribed }) =>
+ componentUnsubscribed ? NotificationText.Unsubscribed : NotificationText.Subscribed
+ )
+ );
+
toggleIssue61(): void {
this.issue61Shown = !this.issue61Shown;
}
@@ -61,4 +76,8 @@ export class InheritanceComponent {
toggleIssue97(): void {
this.issue97Shown = !this.issue97Shown;
}
+
+ toggleIssue175(): void {
+ this.issue175Shown = !this.issue175Shown;
+ }
}
diff --git a/apps/integration/src/app/inheritance/inheritance.module.ts b/apps/integration/src/app/inheritance/inheritance.module.ts
index 23d0627..f2c0bc7 100644
--- a/apps/integration/src/app/inheritance/inheritance.module.ts
+++ b/apps/integration/src/app/inheritance/inheritance.module.ts
@@ -6,6 +6,7 @@ import { InheritanceComponent } from './inheritance.component';
import { Issue61Component } from './issue-61/issue-61.component';
import { Issue97Component } from './issue-97/issue-97.component';
+import { Issue175Component } from './issue-175/issue-175.component';
@NgModule({
imports: [
@@ -13,10 +14,10 @@ import { Issue97Component } from './issue-97/issue-97.component';
RouterModule.forChild([
{
path: '',
- component: InheritanceComponent
- }
- ])
+ component: InheritanceComponent,
+ },
+ ]),
],
- declarations: [InheritanceComponent, Issue61Component, Issue97Component]
+ declarations: [InheritanceComponent, Issue61Component, Issue97Component, Issue175Component],
})
export class InheritanceModule {}
diff --git a/apps/integration/src/app/inheritance/issue-175/issue-175.component.ts b/apps/integration/src/app/inheritance/issue-175/issue-175.component.ts
new file mode 100644
index 0000000..5b39883
--- /dev/null
+++ b/apps/integration/src/app/inheritance/issue-175/issue-175.component.ts
@@ -0,0 +1,21 @@
+import { Directive, Component, OnDestroy } from '@angular/core';
+import { Subject } from 'rxjs';
+import { untilDestroyed, UntilDestroy } from '@ngneat/until-destroy';
+
+@UntilDestroy()
+@Directive()
+export abstract class Issue175Directive {}
+
+@Component({
+ selector: 'app-issue-175',
+ template: '',
+})
+export class Issue175Component extends Issue175Directive implements OnDestroy {
+ constructor() {
+ super();
+
+ new Subject().pipe(untilDestroyed(this)).subscribe();
+ }
+
+ ngOnDestroy(): void {}
+}
diff --git a/libs/until-destroy/src/lib/checker.ts b/libs/until-destroy/src/lib/checker.ts
new file mode 100644
index 0000000..a7a231a
--- /dev/null
+++ b/libs/until-destroy/src/lib/checker.ts
@@ -0,0 +1,174 @@
+import {
+ NgZone,
+ ElementRef,
+ ɵgetLContext,
+ ɵɵdirectiveInject,
+ PLATFORM_ID,
+} from '@angular/core';
+import { isPlatformServer } from '@angular/common';
+import {
+ asapScheduler,
+ catchError,
+ EMPTY,
+ filter,
+ from,
+ mergeMap,
+ MonoTypeOperatorFunction,
+ Observable,
+ observeOn,
+ Subject,
+} from 'rxjs';
+
+import { getSymbol } from './internals';
+
+const CLEANUP = 7;
+
+/**
+ * This function is used within the `UntilDestroy` decorator and returns a
+ * new class that setups a checker that the `destroy$` subject doesn't have
+ * observers (usually `takeUntil`) once the view is removed.
+ * Note: this code will not be shipped into production since it's guarded with `ngDevMode`,
+ * this means it'll exist only in development mode.
+ */
+export function createSubjectUnsubscribedChecker(type: any) {
+ if (isAngularInTestMode()) {
+ return type;
+ }
+
+ return class SubjectUnsubscribedChecker extends type {
+ constructor(...args: any[]) {
+ super(...args);
+
+ try {
+ const ngZone = ɵɵdirectiveInject(NgZone);
+ const platformId = ɵɵdirectiveInject(PLATFORM_ID);
+ const { nativeElement } = ɵɵdirectiveInject(ElementRef);
+
+ // The checker should be executed only in the browser.
+ if (isPlatformServer(platformId)) {
+ return;
+ }
+
+ ngZone.runOutsideAngular(() =>
+ from(Promise.resolve())
+ .pipe(
+ mergeMap(() => {
+ const lContext = ɵgetLContext(nativeElement);
+ if (lContext === null) {
+ return EMPTY;
+ }
+
+ const lCleanup = lContext.lView[CLEANUP] || (lContext.lView[CLEANUP] = []);
+ const cleanupHasBeenExecuted$ = new Subject<
+ Subject | null | undefined
+ >();
+ lCleanup.push(() => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore Type 'symbol' cannot be used as an index type.
+ const destroy$: Subject | null | undefined = this[getSymbol()];
+ cleanupHasBeenExecuted$.next(destroy$);
+ cleanupHasBeenExecuted$.complete();
+ });
+ return cleanupHasBeenExecuted$;
+ }),
+ filter((destroy$): destroy$ is Subject => destroy$ != null),
+ leaveNgZone(ngZone),
+ observeOn(asapScheduler),
+ catchError(() => EMPTY)
+ )
+ .subscribe(destroy$ => {
+ // Note: The `observed` property is available only in RxJS@7.2.0, which will throw
+ // an error in lower versions; that's why it's wrapped into braces. The `observers`
+ // property is also being deprecated.
+ const observed = destroy$['observed'] ?? destroy$['observers'].length > 0;
+ if (observed) {
+ console.warn(createMessage(this));
+ }
+ })
+ );
+ } catch {
+ // Leave the catch statement as empty since we don't have to execute any error handling logic here.
+ }
+ }
+ };
+}
+
+function createMessage(context: any): string {
+ return `
+ The ${context.constructor.name} still has subscriptions that haven't been unsubscribed.
+ This may happen if the class extends another class decorated with @UntilDestroy().
+ The child class implements its own ngOnDestroy() method but doesn't call super.ngOnDestroy().
+ Let's look at the following example:
+
+ @UntilDestroy()
+ @Directive()
+ export abstract class BaseDirective {}
+
+ @Component({ template: '' })
+ export class ConcreteComponent extends BaseDirective implements OnDestroy {
+ constructor() {
+ super();
+
+ someObservable$.pipe(untilDestroyed(this)).subscribe();
+ }
+
+ ngOnDestroy(): void {
+ // Some logic here...
+ }
+ }
+
+ The BaseDirective.ngOnDestroy() will not be called since Angular will call ngOnDestroy()
+ on the ConcreteComponent, but not on the BaseDirective.
+
+ One of the solutions is to declare an empty ngOnDestroy method on the BaseDirective:
+
+ @UntilDestroy()
+ @Directive()
+ export abstract class BaseDirective {
+ ngOnDestroy(): void {}
+ }
+
+ @Component({ template: '' })
+ export class ConcreteComponent extends BaseDirective implements OnDestroy {
+ constructor() {
+ super();
+
+ someObservable$.pipe(untilDestroyed(this)).subscribe();
+ }
+
+ ngOnDestroy(): void {
+ // Some logic here...
+ super.ngOnDestroy();
+ }
+ }
+ `;
+}
+
+/** Gets whether the code is currently running in a test environment. */
+function isAngularInTestMode(): boolean {
+ // We can't use `declare const` because it causes conflicts inside Google with the real typings
+ // for these symbols and we can't read them off the global object, because they don't appear to
+ // be attached there for some runners like Jest.
+ return (
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ (typeof __karma__ !== 'undefined' && !!__karma__) ||
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ (typeof jasmine !== 'undefined' && !!jasmine) ||
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ (typeof jest !== 'undefined' && !!jest) ||
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ (typeof Mocha !== 'undefined' && !!Mocha)
+ );
+}
+
+function leaveNgZone(ngZone: NgZone): MonoTypeOperatorFunction {
+ return function leaveNgZoneOperator(source: Observable) {
+ return new Observable(observer =>
+ source.subscribe(value => ngZone.runOutsideAngular(() => observer.next(value)))
+ );
+ };
+}
diff --git a/libs/until-destroy/src/lib/until-destroy.ts b/libs/until-destroy/src/lib/until-destroy.ts
index df8e44e..fbe1f6e 100644
--- a/libs/until-destroy/src/lib/until-destroy.ts
+++ b/libs/until-destroy/src/lib/until-destroy.ts
@@ -6,6 +6,7 @@ import {
import { Subscription } from 'rxjs';
import { PipeType, isPipe } from './ivy';
+import { createSubjectUnsubscribedChecker } from './checker';
import {
getSymbol,
UntilDestroyOptions,
@@ -13,6 +14,10 @@ import {
markAsDecorated,
} from './internals';
+// This will be provided through Terser global definitions by Angular CLI. This will
+// help to tree-shake away the code unneeded for production bundles.
+declare const ngDevMode: boolean;
+
function unsubscribe(property: unknown): void {
if (property instanceof Subscription) {
property.unsubscribe();
@@ -66,6 +71,9 @@ function decoratePipe(type: PipeType, options: UntilDestroyOptions): void
}
export function UntilDestroy(options: UntilDestroyOptions = {}): ClassDecorator {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore The TS compiler throws 'typeof SubjectUnsubscribedChecker' is assignable to the constraint of type
+ // 'TFunction', but 'TFunction' could be instantiated with a different subtype of constraint 'Function'.
return (type: any) => {
if (isPipe(type)) {
decoratePipe(type, options);
@@ -74,5 +82,9 @@ export function UntilDestroy(options: UntilDestroyOptions = {}): ClassDecorator
}
markAsDecorated(type);
+
+ if (ngDevMode) {
+ return createSubjectUnsubscribedChecker(type);
+ }
};
}