Developers often find scenarios where they are compelled to manaully subscribe to Observables in View components; these scenarios do not use the async pipe levels of manual subscription management. These manual subscriptions can lead to:
- Observables + memory leaks, and
- Zombie subscriptions
To avoid memory leaks these manual Subscription
s must be properly release during the ngOnDestory()
lifecycle phase using the pipeable untilDestroyed()
operator.
Using the untilDestroyed()
function and its pipeable features, an Observable subscription can be easily managed
without adding code bloat to the view compoennt: Here is a refactored version that delegates actions to a smart parent:
export class SomeSmartComponent implements OnDestroy, OnInit {
public tableHasSelections = false;
constructor( private srv: ContentService, private cd: ChangeDetectorRef ) {}
ngOnInit(): void {
const selectionChange$ = this.srv.contentDataTableSelectionAnnounced$;
selectionChange$.pipe(
untilDestroyed(this) // super power here!
).subscribe( selections => {
this.tableHasSelections = selections.length > 0;
this.cd.markForCheck();
});
}
Here is typical implementation of view component logic that tracks and releases a single Subscription
.
export class SomeSmartComponent implements OnDestroy, OnInit {
public tableHasSelections = false;
private _ngUnsubscribe$: Subject<void> = new Subject<void>();
constructor(
private _contentService: ContentService,
private _cd: ChangeDetectorRef,
) {}
ngOnDestroy(): void {
this._ngUnsubscribe$.next();
this._ngUnsubscribe$.complete();
}
ngOnInit(): void {
const selectionChange$ = this.srv.contentDataTableSelectionAnnounced$;
selectionChange$.pipe(
takeUntil(this._ngUnsubscribe$)
)
.subscribe(selections => {
this.tableHasSelections = selections.length > 0;
this._cd.markForCheck();
});
}
...
}
Notice this approach suffers from several issues.
- Requires a
private _ngUnsubscribe$
for EACH subscription - Requires
ngOnDestroy()
logic to unsubscribe or complete the Subscription - Introduces un-necessary code bloat
Zombie Subscriptions are subscriptions that at first appear to be complete (no-longer emitting) and later, unexpectedly emit events. To avoid Zombie, manual subscriptions to synchronous activity must be properly managed and released. This can be achieved achieved using a single observable sequence, takeUntil()
operators, using the @Once()
decorator, etc.
@Directive({
selector: '[xcMediaIf]',
})
export class MediaIfDirective {
@Input() set fxIf(value: string) {
this.matcher.next(value);
}
constructor(
private viewContainer: ViewContainerRef, private templateRef: TemplateRef<any>,
private breakpoints: BreakPointRegistry, private matchMedia: MatchMedia ) {
// create single managed, instance...
this.watchMedia();
}
/**
* Watch for mediaChange(s) on new mediaQuery
*/
private watchMedia(): void {
const updateView = this.updateView.bind(this);
const validateQuery = (query) => {
const breakpoint = this.breakpoints.findByAlias(query);
return breakpoint ? breakpoint.mediaQuery : query;
};
this.matcher.pipe(
untilDestroyed(this),
switchMap(val => {
const query = validateQuery(val);
return query ? this.matchMedia.observe(query) : empty();
})
)
.subscribe(updateView);
}
/**
* Create or clear view instance
*/
private updateView(change: MediaChange) {
if (change.matches && !this.viewRef) {
this.viewRef = this.viewContainer.createEmbeddedView(this.templateRef);
} else if (!change.matches && this.viewRef) {
this.viewContainer.clear();
this.viewRef = undefined;
}
}
private viewRef = undefined;
private matcher = new Subject<string>();
}
@Directive({
selector: '[xcMediaIf]',
})
export class MediaIfDirective implements OnDestroy {
@Input()
set xcMediaIf(value: string) {
this.matcher.next(value);
this.watchMedia(value);
}
constructor(
private viewContainer: ViewContainerRef, private templateRef: TemplateRef<any>,
private breakpoints: BreakPointRegistry, private matchMedia: MatchMedia
) {
}
/**
* Manual cleanup of internal Subscription
*/
ngOnDestroy() {
this.subscription.unsubscribe(); // fix memory leak
}
/**
* Watch for mediaChange(s) on new mediaQuery
*/
private watchMedia(query: string): void {
const updateView = this.updateView.bind(this);
const breakpoint = this.breakpoints.findByAlias(query);
query = breakpoint ? breakpoint.mediaQuery : query;
if (query) {
// !!! Creating a Zombie since we only cache the last subscription
this.subscription = this.matchMedia.observe(query).subscribe(updateView);
}
}
/**
* Create or clear view instance
*/
private updateView(change: MediaChange) {
if (change.matches && !this.viewRef) {
this.viewRef = this.viewContainer.createEmbeddedView(this.templateRef);
} else if (!change.matches && this.viewRef) {
this.viewContainer.clear();
this.viewRef = undefined;
}
}
private subscription: Subscription;
private viewRef = undefined;
private matcher = new Subject<string>();
}
After update to TS 2.9, symbols can no longer be used as an index type. See: microsoft/TypeScript#24587
The following error prevents compilation:
error TS2538: Type 'unique symbol' cannot be used as an index type.
eg. line 70 of the code:
cmpInstance[destroy$] = undefined