rxjstimingswitchmap

Unexpected output with delayed Observable when subscribing right after `next`ing


Let's look at this piece of code:

let isNull = true;
const a$ = new Subject<void>();
const b$ = a$.pipe(
  switchMap(() => {
    if (!isNull)
      return of(true).pipe(
        tap(() => console.log('should be shared')),
        delay(100) // (3)
      );
    else return of(null);
  }),
  shareReplay(1)
);

b$.subscribe((b) => console.log('1', b));
a$.next(); // (1)
b$.subscribe((b) => console.log('2', b));
isNull = false;
a$.next(); // (2)
b$.subscribe((b) => console.log('3', b));

The output is the following:

1 null
2 null
should be shared
3 null // *
1 true
2 true
3 true

Expected output:

1 null
2 null
should be shared
1 true
2 true
3 true

The line marked with * is not desired and I am unsure where it comes from. I assume that the last Observable switchMap returns is not the one updated by (2) but the old one from (1). If I remove the delay at (3), the line marked with * does not appear as expected. This sounds like a timing/ scheduling issue but I do not know a way

EDIT: Made the example more concise.

EDIT 2:

The reason why I am getting null is that during the time of the third subscription, shareReplay does not have the fresh value (true) yet. Instead, it returns the stale value, which is null. A partially correct workaround is to do this:

let isNull = true;
const a$ = new Subject<void>();
const b$ = a$.pipe(
  switchMap(() => {
    if (!isNull)
      return of(true).pipe(
        tap(() => console.log('should be shared')),
        delay(100),
        shareReplay(1)
      );
    else return of(null).pipe(shareReplay(1));
  }),
      share()
);

b$.subscribe((b) => console.log('1', b));
a$.next();
b$.subscribe((b) => console.log('2', b));
isNull = false;
a$.next();
b$.subscribe((b) => console.log('3', b));

// Output:
/*
1 null
should be shared
1 true
2 true
3 true
*/

But as you see, the output "2 null" is missing. So, I am still not sure how to solve this issue elegantly.


Solution

  • The reason why null is logged instead of true is that the emission of true reaches the shareReplay(1) operator after it has already returned the previously buffered value (null) to the second subscriber.

    This happens because the delay operator schedules the emission of true within a macrotask, which occurs after the synchronous subscription process is completed (wherein shareReplay(1) returns its buffered value to the subscriber). Therefore, the update of shareReplay(1)'s buffer does not happen in time or, to put it another way, the subscriber does not wait for the update to complete.

    This should solve the problem (adapted from here):

    let isNull = true;
    const a$ = new Subject<void>();
    const b$ = a$.pipe(
      map(() => {
        if (!isNull)
          return of(true).pipe(
            tap(() => console.log('should be shared')),
            delay(100),
            shareReplay(1)
          );
        else return of(null);
      }),
      shareReplay(1),
      switchAll()
    );
    
    b$.subscribe((b) => console.log('1', b));
    a$.next();
    b$.subscribe((b) => console.log('2', b));
    isNull = false;
    a$.next();
    b$.subscribe((b) => console.log('3', b));
    
    // Output:
    /*
    1 null
    2 null
    should be shared
    1 true
    2 true
    3 true
    */