diff --git a/src/app/signal-error/signal-error.component.ts b/src/app/signal-error/signal-error.component.ts index 210ebd1..45ed8ae 100644 --- a/src/app/signal-error/signal-error.component.ts +++ b/src/app/signal-error/signal-error.component.ts @@ -1,4 +1,5 @@ import { Component, computed, signal } from '@angular/core'; +import { map, timer } from 'rxjs'; import { asyncComputed } from 'src/utils/signals/async-computed'; @Component({ @@ -17,7 +18,16 @@ import { asyncComputed } from 'src/utils/signals/async-computed'; styleUrl: './signal-error.component.css', }) export default class SignalErrorComponent { - $count = signal(10); + $count = signal(11); + + faultyTimer$ = timer(0, 500).pipe( + map(i => { + if (i === 10) { + throw new Error('You left me hanging...'); + } + return i; + }) + ); even = async n => { await new Promise(r => setTimeout(r, 250)); @@ -28,12 +38,19 @@ export default class SignalErrorComponent { } }; - $result = asyncComputed(async () => { + stupidFn = asyncComputed(() => { const c = this.$count(); + if (c % 3 === 0) { + return this.faultyTimer$; // return an observable that will throw an error in time + } + return this.even(c); // return a promise that will throw an error if the number is not even + }); + + $result = computed(() => { try { - return await this.even(c); + return this.stupidFn(); } catch (error: any) { - console.error(error); + // console.error(error); return `Error: ${error.message}`; } }); diff --git a/src/app/signal-play/signal-play.service.ts b/src/app/signal-play/signal-play.service.ts index 3430b7d..155f127 100644 --- a/src/app/signal-play/signal-play.service.ts +++ b/src/app/signal-play/signal-play.service.ts @@ -1,7 +1,7 @@ import { computed, inject, Injectable } from '@angular/core'; import { DemoUserService } from '../demo-users.service'; -import { observableComputed } from 'src/utils/signals/observable-computed'; +import { asyncComputed } from 'src/utils/signals/async-computed'; @Injectable({ providedIn: 'root', @@ -9,7 +9,7 @@ import { observableComputed } from 'src/utils/signals/observable-computed'; export class SignalPlayService { users = inject(DemoUserService); - $users = observableComputed(() => this.users.allUsers$); + $users = asyncComputed(() => this.users.allUsers$); getUser = (n: string) => { const users = this.$users(); diff --git a/src/utils/signals/async-computed.ts b/src/utils/signals/async-computed.ts index c4c1570..45be8bb 100644 --- a/src/utils/signals/async-computed.ts +++ b/src/utils/signals/async-computed.ts @@ -1,69 +1,81 @@ /* eslint-disable max-len */ -import { computed, DestroyRef, effect, inject, type Signal, signal } from '@angular/core'; -import { firstValueFrom, isObservable, Observable } from 'rxjs'; -import { isPromise } from 'util/types'; +import { computed, DestroyRef, effect, inject, isDevMode, type Signal, signal } from '@angular/core'; +import { isObservable, Observable, type Subscription } from 'rxjs'; +import { isPromise } from './is-promise'; -type AsyncComputedFn = () => Promise | Observable | T; +type ObservableComputedFn = () => Observable | Promise | T; interface AsyncComputed { /** - * @description Helper to get the result of a promise, or the first emission form a observable into an signal. + * @description Helper to put the outcome(s) of a promise or observable into a signal * @template T type to use for the output signal, defaults to return type of the AsyncComputedFn - * @param {AsyncComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives + * @param {ObservableComputedFn} AsyncComputedFn function returning a promise or observable or a value. the result is put into the signal when it arrives * @returns {*} {(Signal)} */ - (cb: AsyncComputedFn): Signal; + (cb: ObservableComputedFn): Signal; /** - * @description Helper to get the result of a promise, or the first emission form a observable into an signal. + * @description Helper to put the outcome(s) of a promise or observable into a signal * @template T type to use for the output signal, defaults to return type of the AsyncComputedFn * @template Y Type to use for the initalValue, default to the type of the initialValue - * @param {AsyncComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives + * @param {ObservableComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives * @param {Y} [initialValue] the initial value of the signal. * @returns {*} {(Signal)} */ - (cb: AsyncComputedFn, initialValue: Y): Signal; + (cb: ObservableComputedFn, initialValue: Y): Signal; /** - * @description Helper to get the result of a promise, or the first emission form a observable into an signal. + * @description Helper to put the outcome(s) of a promise or observable into a signal * @template T type to use for the output signal, defaults to return type of the AsyncComputedFn * @template Y Type to use for the initalValue, default to the type of the initialValue - * @param {AsyncComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives + * @param {ObservableComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives * @param {Y} [initialValue] the initial value of the signal. * @param {DestroyRef} [destroyRef] a manual provided destroyRef. Mandatory when the function is used outside a injection context * @returns {*} {(Signal)} */ - (cb: AsyncComputedFn, initialValue: Y, destroyRef: DestroyRef): Signal; + (cb: ObservableComputedFn, initialValue: Y, destroyRef: DestroyRef): Signal; } export const asyncComputed: AsyncComputed = ( - cb: AsyncComputedFn, + cb: ObservableComputedFn, initialValue?: Y, destroyRef = inject(DestroyRef) ): Signal => { const state = signal({ value: initialValue, error: undefined, + // not adding the completed state. a Signals has no way to communicate this + // to its consumers without custom wrapping. That is a different concern that + // is outside the scope of this helper } as { value?: T | Y | undefined; error?: any }); if (!destroyRef) { throw new Error('destroyRef is mandatory when used outside a injection context'); } - destroyRef.onDestroy(() => ref.destroy()); + destroyRef.onDestroy(() => { + obs?.unsubscribe(); + ref.destroy(); + }); + let obs: Subscription | undefined; const ref = effect( async () => { - let value: T | Y | undefined; try { + obs?.unsubscribe(); // cleanup previous subscription (on new signal emission) const outcome = cb(); if (isObservable(outcome)) { - value = await firstValueFrom(outcome); + obs = outcome.subscribe({ + next: value => state.set({ value, error: undefined }), + error: error => { + state.set({ value: undefined, error }); + }, + }); } else if (isPromise(outcome)) { - value = await outcome; + const value = await outcome; + state.set({ value, error: undefined }); } else { - value = outcome; + state.set({ value: outcome, error: undefined }); } - state.set({ value, error: undefined }); } catch (e) { state.set({ value: undefined, error: e }); } }, - { manualCleanup: true } + { manualCleanup: true, allowSignalWrites: true } ); return computed(() => { diff --git a/src/utils/signals/observable-computed.ts b/src/utils/signals/old-async-computed.ts similarity index 53% rename from src/utils/signals/observable-computed.ts rename to src/utils/signals/old-async-computed.ts index 72f5ddf..fb51989 100644 --- a/src/utils/signals/observable-computed.ts +++ b/src/utils/signals/old-async-computed.ts @@ -1,91 +1,78 @@ /* eslint-disable max-len */ import { computed, DestroyRef, effect, inject, type Signal, signal } from '@angular/core'; -import { firstValueFrom, isObservable, Observable, type Subscription } from 'rxjs'; -import { isPromise } from './is-promise'; +import { firstValueFrom, isObservable, Observable } from 'rxjs'; +import { isPromise } from 'util/types'; -type ObservableComputedFn = () => Observable | Promise | T; +type AsyncComputedFn = () => Promise | Observable | T; interface AsyncComputed { /** * @description Helper to get the result of a promise, or the first emission form a observable into an signal. * @template T type to use for the output signal, defaults to return type of the AsyncComputedFn - * @param {ObservableComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives + * @param {AsyncComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives * @returns {*} {(Signal)} */ - (cb: ObservableComputedFn): Signal; + (cb: AsyncComputedFn): Signal; /** * @description Helper to get the result of a promise, or the first emission form a observable into an signal. * @template T type to use for the output signal, defaults to return type of the AsyncComputedFn * @template Y Type to use for the initalValue, default to the type of the initialValue - * @param {ObservableComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives + * @param {AsyncComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives * @param {Y} [initialValue] the initial value of the signal. * @returns {*} {(Signal)} */ - (cb: ObservableComputedFn, initialValue: Y): Signal; + (cb: AsyncComputedFn, initialValue: Y): Signal; /** * @description Helper to get the result of a promise, or the first emission form a observable into an signal. * @template T type to use for the output signal, defaults to return type of the AsyncComputedFn * @template Y Type to use for the initalValue, default to the type of the initialValue - * @param {ObservableComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives + * @param {AsyncComputedFn} AsyncComputedFn function returning a promise or observable. the result is put into the signal when it arrives * @param {Y} [initialValue] the initial value of the signal. * @param {DestroyRef} [destroyRef] a manual provided destroyRef. Mandatory when the function is used outside a injection context * @returns {*} {(Signal)} */ - (cb: ObservableComputedFn, initialValue: Y, destroyRef: DestroyRef): Signal; + (cb: AsyncComputedFn, initialValue: Y, destroyRef: DestroyRef): Signal; } -export const observableComputed: AsyncComputed = ( - cb: ObservableComputedFn, +export const oldAsyncComputed: AsyncComputed = ( + cb: AsyncComputedFn, initialValue?: Y, destroyRef = inject(DestroyRef) ): Signal => { const state = signal({ value: initialValue, error: undefined, - // not adding the completed state. a Signals has no way to communicate this - // to its consumers without custom wrapping. That is a different concern that - // is outside the scope of this helper } as { value?: T | Y | undefined; error?: any }); if (!destroyRef) { throw new Error('destroyRef is mandatory when used outside a injection context'); } - destroyRef.onDestroy(() => { - obs?.unsubscribe(); - ref.destroy(); - }); - let obs: Subscription | undefined; + destroyRef.onDestroy(() => ref.destroy()); const ref = effect( async () => { + let value: T | Y | undefined; try { - obs?.unsubscribe(); // cleanup previous subscription (on new signal emission) const outcome = cb(); if (isObservable(outcome)) { - obs = outcome.subscribe({ - next: value => state.set({ value, error: undefined }), - error: error => { - state.set({ value: undefined, error }); - }, - }); + value = await firstValueFrom(outcome); } else if (isPromise(outcome)) { - const value = await outcome; - state.set({ value, error: undefined }); + value = await outcome; } else { - state.set({ value: outcome, error: undefined }); + value = outcome; } + state.set({ value, error: undefined }); } catch (e) { - // this is needed because there might be an error in the CD that is not inside the observable stream state.set({ value: undefined, error: e }); } }, - { manualCleanup: true, allowSignalWrites: true } + { manualCleanup: true } ); return computed(() => { const currentState = state(); if (currentState.error) { + // rethrow error to be handled by the user throw currentState.error; + // note to self: do not wrap this in a new error, as it will hide the original stack trace } return currentState.value; }); }; - -