Skip to content

Instantly share code, notes, and snippets.

@ThomasBurleson
Last active December 5, 2019 15:05
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ThomasBurleson/df0d9554b5d30d365cd8737a36d69fe3 to your computer and use it in GitHub Desktop.
Save ThomasBurleson/df0d9554b5d30d365cd8737a36d69fe3 to your computer and use it in GitHub Desktop.
Refactor #4: RxJS Memory Leaks, Zombie Subscriptions, & Errors

πŸ’š Best Practices: Avoid 3 Common RxJS Issues

Bad-RxJS Issues

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


Observables + Memory Leaks

To avoid memory leaks these manual Subscriptions must be properly release during the ngOnDestory() lifecycle phase using the pipeable untilDestroyed() operator.

Improved Version πŸ˜„:

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();
    });
}

Original Version 🧐:

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

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.

Improved Version πŸ˜„:
@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>();

}

Demo Bad-RxJS: Fix to Avoid Zombies


Original Version 🧐:
@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>();

}

Demo Bad-RxJS: Creating Zombies

@nate-knight
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment