0

I'm trying to create an in-memory singleton that holds the current vendor a person is browsing on.

A guard is used on all specific routes to catch the parameter:

canActivate(
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot): Observable<boolean | UrlTree> {

  let currentUrl = this._router.url;
  const param = route.params['vendname'];

  return this._vendorService.getByName(param).pipe(map(a => {
    if (a == null) {
      this._snackBarService.open('Vendor not found', 'x', { duration: 5000 });
      return this._router.parseUrl(currentUrl);
    }
    return true;
  }));
}

A service is used to get the vendor by name. If it exists in-memory return it. If it doesn't, get it from the server first.

set vendor(value: IUser) {
  this._vendor.next(value);
}

get vendor$(): Observable<IUser> {
  return this._vendor.asObservable();
}

getByName(name: string): Observable<IUser> {
  const result = this.vendor$.pipe(map(v => {
    if (v != null && v.displayName == name) {
      return v;
    }
    else {
      return this.Get<IUser>(`api/vendor/${name}`).pipe(switchMap(v => {
        this.vendor = v;
        return of(v)
        // ...
      }));
    }
  }))
  return result;
}

The problem is I need to check vendor$ for its value which returns an Obervable<IUser> but the switchMap also returns an Obervable<IUser>, causing the result to be Observable<Observable<IUser>>. How can I make the result return a single User Observable?

Jnr
  • 1,504
  • 2
  • 21
  • 36
  • 1
    Return `v` instead of `of(v)` – Aviad P. Jul 24 '21 at 14:53
  • ``return this.Get(`api/vendor/${name}`).pipe`` always returns an observable, so need to use a swtich map in the vendors$ pipe as well ``this.vendor$.pipe(map(v => {``, but be aware you need to transform non observable returns in you switchmap to observables – Thomas Renger Jul 24 '21 at 15:15
  • Additionally: Why do you use ``this.Get(`api/vendor/${name}`).pipe(switchMap(v => {`` ? i cannot see a scenario for switchmap or is your code just shorter nd the ``...`` contains the real switchmap? – Thomas Renger Jul 24 '21 at 15:17
  • There is no subscription ever done on the call to `getByName` because it is called from my `guard`. So, I use switchmap that (as I am aware) does an inner-subscription. – Jnr Jul 25 '21 at 07:58

3 Answers3

1

I believe you are misunderstanding the use case of switchMap() becuase it's not the cause of your Observable<Observable<IUser>>. Your first map() operator inside getByName() is either returning a value of type IUser (when true) or an observable of IUser (when false). So getByName() returns either Observable<IUser> or Observable<Observable<IUser>>.

However, if you want to try to utilize replaying in-memory values from an observable, then I would recommend shareReplay(). Also, here's a recommended pattern for such a case.

private vendorName = new Subject<string>();

public vendor$ = this.vendorName.pipe(
  distinctUntilChanged(),
  switchMap(name=>this.Get<IUser>(`api/vendor/${name}`)),
  shareReplay(1)
)

public getByName(name:string){
  this.vendorName.next(name);
}

Then in the guard file.

canActivate(
  // ...
){
  let currentUrl = this._router.url;
  const param = route.params['vendname'];

  this._vendorService.getByName(param);

  return this._vendorService.vendor$.pipe(
    map(vendor=>{
      if(!vendor){
        this._snackBarService.open('Vendor not found', 'x', { duration: 5000 });
        return this._router.parseUrl(currentUrl);
      }
      return true;
    })
  );

With this setup, your component(s) & guard can subscribe to vendor$ to get the IUser data it needs. When you have the name to get the vendor, you can invoke getByName(). This will trigger the following steps:

  1. The userName subject will emit the new name.
  2. The vendor$ observable (subscribed to userName) will take that name, then switchMap to get the value from the inner observable (the Get method)
  3. The shareReplay(1) ensures any subscription to vendor$ (current or future) will get the last (1) emitted value from memory.

If you need to update the vendor name, just invoke getByName() with your new name and everything subscribed to vendor$ will automatically get the updated value.

Joshua McCarthy
  • 1,739
  • 1
  • 9
  • 6
  • I couldn't get it to check if the name already exists before calling the server. I don't want to call the server on each angular route. – Jnr Jul 25 '21 at 16:08
  • Apologies, I updated my example to fit with your use case. I added `distinctUntilChanged()` before calling the API. This prevents duplicate calls being made if `vendorName` emits a duplicate name. I also included the guard update that calls the method to emit the new param, then returns the same logic based on the last emitted value of `vendor$`. – Joshua McCarthy Jul 26 '21 at 02:12
  • That worked, BUT I still needed to be able to manually add the URL with a vendor name in the browser address bar. Also, I was getting strange behaviors when navigating. Change your `Subject` to `ReplaySubject(1)` so that it only keeps track of the single value. – Jnr Jul 26 '21 at 07:24
0

related to what I see vendor$ is a stream of IUser, here is a refactoring of your code using iif rxjs operator to subscribe to the first or the second observable based on a your condition

 this.vendor$.pipe(
    mergeMap(v => iif(
      () => v && v.displayName == name, of(v), this.Get<IUser>(`url`).pipe(tap(v => this.vendor = v))
     )
    )
  )
Fateh Mohamed
  • 20,445
  • 5
  • 43
  • 52
0

I'm adding another solution that worked.

Seeing as though the canActivate returns a Promise as well, you can also use the trusty old async await method.

Note: the toPromise is now lastValueFrom in rxjs 7.

async getByVendorName(name: string): Promise<IUser> {
  const v:IUser = await new Promise(resolve => { 
    this.vendor$.subscribe(a => {
      if (a != null && a.displayName.toLocaleLowerCase() == name) {
        resolve(a);
      }
    });
    resolve(null);
  });

  if (v == null) {
    return this.Get<IUser>(`api/vendor/${name}`).pipe(switchMap(v => {
      this.vendor = v;
      return of(v);
    })).toPromise();

  }
  return Promise.resolve(v);
}
Jnr
  • 1,504
  • 2
  • 21
  • 36