ngxtension / ngxtension-platform

Utilities for Angular

Home Page:https://ngxtension.netlify.app/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Signal Operator Proposal: LazyComputedAsync

Bengejd opened this issue · comments

Would it be possible to create a LazyComputedAsync operator? In essence, this would behave exactly like a lazySignal where the subscription isn't created until the signal is read for the first time, while also benefitting from computeAsync's ability to re-compute as internal signal values update. This would prevent extraneous requests from being processed if you looking at a view where the computeAsync signal itself isn't being read, but still exists within a data object that is.

Yeah, I can see how that method would be beneficial, but even the author explains:

The one missing feature is automatic deferment. Our source observable is not completely deferred, because the component subscribes as soon as it is created and injects the service, rather than waiting for an actual subscription in the template. However, this is good enough for the vast majority of use cases.

Which the deferment is partially what I am looking for at the moment. A ComputedAsync that doesn't automatically subscribe until it is read.

For example, imagine that you have a list of users being displayed. Let's say for this example, a user object is structured like so:

{ id: UUID; firstName: string; lastName: string; dateOfBirth: ISO8601; } 

The API call to get the list of users is a specialized endpoint, where it only returns partial user objects. So for arguments sake, let's say it includes everything except for dateOfBirth. When you click on a user, you fire off an API call to retrieve that full user object & navigate to their user page. This is the deferment that I am looking for. Typically, this will be done by subscribing to an observable in a component function. Or you can similarly do it in the template via nested *ngIfs like so:

<ng-container *ngIf="condition"> 
  <ng-container *ngIf="(fetchUser$ | async) as user">
    // Content that leverages user
</ng-container>
</ng-container>

So in my ideal world I would be able to do something like this:

user: InputSignal<partial<User>> = input.required();
$fetchUser: Signal<User> = lazyComputedAsync( () => this.api.fetchUser( this.user().id ).pipe( shareReplay(1) ) );

public someAction() {
  console.log({ fullUser: this.$fetchUser() });
}

And utilize it in the template like so:

<button (click)="someAction()" label="some button" />

<ng-container *ngIf="condition"> 
  <some-component [user]="$fetchUser()" />
</ng-container>

This way, either the template conditional will cause the subscription (because the signal is being read in the rendered template) or the button click will cause the subscription (because it is being read in the function), but the internal API call won't be subscribed to (and won't fire off) earlier than this.

I suppose I could probably simply create the deferred API call with a lazySignal and simply re-assign it via an effect when the user signal changes. I hadn't considered that until now, so I'm not entirely sure how it'll play out.

Edit:

It seems like that methodology works. I'm not 100% sure if there will be unintended side effects of this, but the following seems to work exactly how I'd like it to:

injector = inject(Injector);
user: InputSignal<partial<User>> = input.required();
$fetchUser = this.fetchUser;

constructor() {
  effect( () => {
    this.$fetchUser = this.fetchUser;
  }
}

public get fetchUser(): Signal<User> {
  return toLazySignal(
    of(this.user().id)
   .pipe( 
      skipWhile (value) => value === undefined),
      switchMap( () => this.api.fetchUser( this.user().id ) ),
    ), { injector: this.injector }
  )
}

Again, I'm not entirely sure if there will be side effects to this such as memory leaks due to the reassignment of the signal or what have you. But it does seem to accomplish exactly what I wanted to do. I imagine that the previous lazySignal will be cleaned up when the injection context is destroyed.

@eneajaho I'm sure there is a more elegant way of handling this, but this is the function that I came up with to self-contain the above. It's just a modification of toLazySignal.

export function lazyComputedAsync<T, U = undefined>(
  computation: () => Observable<T> | Subscribable<T>,
  options: ToSignalOptions,
): Signal<ReturnType<T, U>> {
  const injector = assertInjector(lazyComputedAsync, options?.injector);
  let s: Signal<ReturnType<T, U>>;
  const identifier: WritableSignal<UUID> = signal(undefined);
  const previousIdentifier: WritableSignal<UUID> = signal(undefined);
  const computedObservable = computed(() => computation());

  effect(
    () => {
      if (computedObservable()) {
        untracked(() => identifier.set(new UUID()));
      }
    },
    { injector },
  );

  return computed<ReturnType<T, U>>(() => {
    const prevId = untracked(() => previousIdentifier());
    const currentId = identifier();

    if (!s || prevId !== currentId) {
      s = untracked(() => toSignal(computedObservable(), { injector }));
      untracked(() => previousIdentifier.set(currentId));
    }
    return s();
  });
}

Usage:

$fetchUser: Signal<User> = lazyComputedAsync( () => this.fetchUser, { options: this.injector } );

public get fetchUser(): Observable<User> {
  return of(this.user().id)
   .pipe( 
      skipWhile (value) => value === undefined),
      switchMap( () => this.api.fetchUser( this.user().id ) ),
    )
}

Any thoughts?

I am not sure but, I think we can just use the implementation of toLazySignal and put a computedAsync into it.

Something like this:

export function lazyComputedAsync<T, U = undefined>(
  computation: (
    previousValue?: T | undefined,
  ) => Observable<T> | Promise<T> | T | undefined,
  options?: ToSignalOptions & { initialValue?: U },
): Signal<ReturnType<T, U>> {
  const injector = assertInjector(lazyComputedAsync, options?.injector);
  let s: Signal<ReturnType<T, U>>;

  return computed<ReturnType<T, U>>(() => {
    if (!s) {
      s = untracked(() =>
        computedAsync(computation, { ...options, injector } as any),
      );
    }
    return s();
  });
}