Daniel Kucal
Daniel Kucal

Reputation: 9242

RxJS 6 Pause or buffer observable when the page is not active

So I have a stream of, let's say letters, and I need all letters in the right order to put them together into a word. Everything works fine until the user changes tab, minimize browser or switches application - behavior is almost the same as if I was using setTimeout() - messed up the order, lost items, etc. I tried to achieve my goal by using bufferWhen(), bufferToggle(), takeUntil(), publish() and connect() but nothing succeeded. I considered to use delayWhen as well, but it's deprecated and probably not suitable as it stops the stream immediately. Which functions should I use and how? Here's my code:

export class MyComponent implements AfterViewInit {
  private visibilityChange$ = fromEvent(document, 'visibilitychange').pipe(startWith('visible'), shareReplay({ refCount: true, bufferSize: 1 }));
  private show$ = this.visibilityChange$.pipe(filter(() => document.visibilityState === 'visible'));
  private hide$ = this.visibilityChange$.pipe(filter(() => document.visibilityState === 'hidden'));

  public ngAfterViewInit() {
    const lettersStream$ = zip( // add delay for each letter
            from(['w', 'o', 'r', 'd']),
            interval(1000))
           // pause when hide$ fires, resume when show$
          .pipe(map(([letter, delayTime]) => letter))
          .subscribe(console.log);
  }
}

I made a demo on stackblitz - all I want is to see (stop writing when the tab is inactive) how the phrase is written on the screen.

Upvotes: 3

Views: 2781

Answers (2)

Goga Koreli
Goga Koreli

Reputation: 2947

Since I have done similar pause/unpause thing in my RxJS Snake Game, I will help you with your example.

Idea is to have an interval(1000) as a source of truth, which means that everything will be based on it. So our goal becomes to make this interval pausable, based on the fact that we need to stop emitting events on visibility hide and continue on visibility show. Finally to make things easier, we can just stop listening to the source interval on visibility hide and start listening again when visibility show arrives. Let's now go to the exact implementation:

You can play with the modified StackBlitz demo code as well at RxJS Pause Observable.

import { of, interval, fromEvent, timer, from, zip, never } from 'rxjs';
import { delayWhen, tap, withLatestFrom, concatMap, take, startWith, distinctUntilChanged, switchMap, shareReplay, filter, map, finalize } from 'rxjs/operators';

console.log('-------------------------------------- STARTING ----------------------------')

class MyComponent {
  private visibilityChange$ = fromEvent(document, 'visibilitychange')
    .pipe(
      map(x => document.visibilityState),
      startWith('visible'),
      shareReplay(1)
    );

  private isVisible$ = this.visibilityChange$.pipe(
    map(x => x === 'visible'),
    distinctUntilChanged(),
  );

  constructor() {
    const intervalTime = 1000;
    const source$ = from('word or two'.split(''));
    /** should remove these .pipe(
        concatMap(ch => interval(intervalTime).pipe(map(_ => ch), take(1)))
      );*/

    const pausableInterval$ = this.isVisible$.pipe(
      switchMap(visible => visible ? interval(intervalTime) : never()),
    )

    const lettersStream$ = zip(pausableInterval$, source$).pipe(
      map(([tick, letter]) => letter),
    ).subscribe(letter => {
      this.writeLetter(letter);
    });
  }

  private writeLetter(letter: string) {
    if (letter === ' ') letter = '\u00A0'; // fix for spaces
    document.body.innerText += letter;
  }
}

const component = new MyComponent();

This is exact code from StackBlitz, I copied here to explain better for you.

Now lets break down interesting parts for you:

  1. Have a look at visibilityChange$ and isVisible$. They are modified a little, so that first one emits string value 'visible' or 'hidden' based on document.visibilityState. Second one emits true when document.visibilityState equals 'visible'.

  2. Have a look at source$. It will emit a letter and then wait 1 second with the help of concatMap and interval with take(1) and do this process until there is no character left in the text.

  3. Have a look pausableInterval$. Based on this.isVisible$ which will change according to the document.visibilityState, our pausableInterval$ will be emiting item per second or will not emit anything at all due to never().

  4. Finally have a look at lettersStream$. With the help of zip(), we will zip pausableInterval$ and source$, so we will get one letter from source and one tick from pausable interval. If pausableInterval$ stops emitting due to visibilitychange, zip will wait as well, since it needs both Observables to emit together to send event to the subscribe.

Upvotes: 6

bryan60
bryan60

Reputation: 29355

a little confused on the use case, but this may solve it:

first instead do this:

private isVisible$ = this.visibilityChange$.pipe(
                       filter(() => document.visibilityState === 'visible'), 
                       distinctUntilChanged()); // just a safety operator

then do this:

const lettersStream$ = this.isVisible$.pipe(
        switchMap((isVisible) => (isVisible)
          ? zip( // add delay for each letter
              from(['w', 'o', 'r', 'd']),
              interval(1000))
            .pipe(map(([letter, delayTime]) => letter))
          : NEVER
        )
      ).subscribe(console.log);

just switchMap everytime the visibility changes, subscribe to the source if visible, do nothing if not.

with this contrived example, the behavior will be a little wonky because from() will always emit the same sequence but with a real non static source, it should work as intended.

Upvotes: 2

Related Questions