2

I'm implementing an analytics service into my Angular app. I create and append the 3rd party script in the service's constructor. There can be a race condition where other services try to fire telemetry events before the script is loaded. I wanted to create a buffer on the telemetry methods to hold messages until the script loads and then to flush the buffer and then push as normal after that.

Pseudo code:

// Global variable set by 3rd party library after it loads
declare let analytics: any;

class AnalyticsService {
  isLoaded$ = new BehaviorSubject<boolean>(false);
  identify$ = new BehaviorSubject<user>(null);

  constructor() {
    this.loadScript();

    // Here, I want to buffer messages until the 3rd party script initializes
    // Once isLoaded$ fires, forEach the queue and pass to the 3rd party library
    // Then for every message after, send one at a time as normal like 
    // the buffer wasn't there
    this.identify$
      .pipe(buffer(this.isLoaded$.pipe(skip(1)) // Essentially want to remove this after it fires
      .subscribe((user) => analytics.identify(user));
  }

  loadScript(): void {
    const script = document.createElement('script');
    script.innerHTML = `
      // setup function from 3rd party
    `;

    document.querySelector('head').appendChild(script);

    interval(1000)
      .pipe(take(5), takeUntil(this.isLoaded$))
      .subscribe(_ => {
        if (analytics) this.isLoaded$.next(true);
      })
  }

  identify(user): void {
    this.identify$.next(user);
  }
}

If I were to use two subscriptions, it would be like

identify$
  .pipe(
    takeUntil(this.isLoaded$),
    buffer(this.isLoaded$),
  ).subscribe(events => events.forEach(user => analytics.identify(user)));

identify$
  .pipe(
    filter(_ => this.isLoaded$.value),
  ).subscribe(user => analytics.identify(user))

Is there a way to do this with one subscription?

Dan
  • 919
  • 1
  • 10
  • 28

2 Answers2

2

Here might be a way to achieve what you're looking for:

constructor () {
  this.loadScript();

  this.identify$
    .pipe(
      buffer(
        concat(
          this.isLoaded$.pipe(skip(1), take(1)),
          this.identify.pipe(skip(1)),
        )
      )
    )
    .subscribe((user) => analytics.identify(user));
}

The gist resides in

concat(
  this.isLoaded$.pipe(skip(1), take(1)),
  this.identify$.pipe(skip(1)),
)

So, we first wait for isLoaded$ to emit true, then this.identify.pipe(skip(1)) will be subscribed.

After the script is loaded, you want to proceed immediately when this.identify$ emits. And here is why we subscribe to it once more, from the buffer's closing notifier. Basically, this.identify$ will have 2 subscribers now. The first one is subscribe((user) => analytics.identify(user)) and the second one is the one from concat(which is buffer's closing notifier). When this.identify$ emits, the value will be sent to its subscribers in order. So, the value will end up being added into the buffer and then immediately passed along to the next subscriber in the chain, because this.identify$'s second subscriber will receive the value synchronously.

Andrei Gătej
  • 11,116
  • 1
  • 14
  • 31
  • Could you clarify what the order is? Your 4th sentence says "The first one is subscribe()..." and your last sentence says "So, the value will end up being added into the buffer and then immediately passed along to the next subscriber...". The buffer pipe is first right? – Dan Dec 19 '20 at 16:37
  • 1
    I’m sorry, it is indeed a bit confusing. The second one is the one which comes from buffer’s inner subscription. And because it is the second, when it emits, the buffer will already contain the initially emitted value. – Andrei Gătej Dec 19 '20 at 16:41
1

Here's a way that doesn't need a buffer. We delay each value until this.isLoaded$ emits true. Since it looks like this.isLoaded$ is a BehaviorSubject, it will emit immediately once it's true otherwise it will delay the delivery of the value.

identify$.pipe(
  delayWhen(() => this.isLoaded$.pipe(
    filter(x => x)
  )),
).subscribe(user => analytics.identify(user));

Update # 1: A custom operator

Here's a custom operator that delays emissions while a function returns true and emits buffered and current values while the function returns false.

Unlike the previous solution, this is will preserve the order of emissions.

function delayWhile<T>(fn: (T)=>boolean): MonoTypeOperatorFunction<T>{
  return s => new Observable<T>(observer => {
    const buffer = new Array<T>();
    const sub = s.subscribe({
      next: (v:T) => {
        if(fn(v)){
          buffer.push(v);
        }else{
          if(buffer.length > 0){
            buffer.forEach(observer.next.bind(observer));
            buffer.length = 0;
          }
          observer.next(v);
        }
      },
      complete: observer.complete.bind(observer),
      error: observer.error.bind(observer)
    })
    return {unsubscribe: () => {sub.unsubscribe()}};
  });
}

Here it is in use:

identify$.pipe(
  delayWhile(() => !this.isLoaded$.value)
).subscribe(user => analytics.identify(user));
Mrk Sef
  • 7,557
  • 1
  • 9
  • 21
  • Just to clarify, this will preserve the values that emit before isLoaded$ and play them in order? It doesn't do anything like only take the last value? – Dan Dec 19 '20 at 16:07
  • 1
    It should, though I guess there isn't a built-in guarantee that they'll be emitted in order, I think they will be based on how they're added in JS's event loop. If the order is super important, you may want a different solution. – Mrk Sef Dec 19 '20 at 16:15
  • Updated with a custom operator that buffers delayed values internally and emits them based on a function – Mrk Sef Dec 19 '20 at 16:38