import { Observable, combineLatest, debounceTime, filter, map, of, switchMap, take, throwError, } from 'rxjs'; import { EffectError, EffectResult, ToEffectError, isEffectError, isNotEffectError, toEffectError, } from './effect-result'; import { Signals, SignalsFactory } from './signals-factory'; import { Effect, SafeEffectResult, Store, UnhandledEffectError } from './store'; import { DerivedId, EffectId, EventId, NO_VALUE, NoValueType, getDerivedId, getEffectId, getEventId, getStateId, isNoValueType, isNotNoValueType, } from './store-utils'; /** * Value-type for the combined derived behavior produced by {@link EffectSignals}. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type CombinedEffectResult = { /** * The current input (which might differ from the resultInput), * or `NO_VALUE`, if no input was received yet */ currentInput: Input | NoValueType; /** * The current effect-result, * or `NO_VALUE` if no result was received yet, * or the effect produced an error */ result: SafeEffectResult | NoValueType; /** * The input that produced the current effect-result, * or `NO_VALUE`, if initial result or no result received yet */ resultInput: Input | NoValueType; /** * Indicates whether the effect is currently running. * In case of a factory without trigger, this will be true whenever one or multiple * of the following conditions are met: * `currentInput !== resultInput`, * or an invalidation event has been sent, * or the effect has sent a result, but has not yet completed. * In case of a factory with result-trigger, in addition to the previous * criteria, a trigegr event must have been received. */ resultPending: boolean; }; /** * Type representing a {@link CombinedEffectResult} in it's error state (non-pending) * * @template Input - specifies the input type for the effect * @template Error - specifies the error type of the effect */ export type CombinedEffectResultInErrorState = { currentInput: Input; result: ToEffectError | EffectError; resultInput: Input; resultPending: false; }; /** * Typeguard to check if a {@link CombinedEffectResult} is a {@link CombinedEffectResultInErrorState} */ export const isCombinedEffectResultInErrorState = ( cer: CombinedEffectResult, ): cer is CombinedEffectResultInErrorState => isEffectError(cer.result); /** * Type representing a {@link CombinedEffectResult} in it's success state (pending or non-pending) * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect */ export type CombinedEffectResultInSuccessState = { currentInput: Input | NoValueType; result: Result; resultInput: Input | NoValueType; resultPending: boolean; }; /** * Typeguard to check if a {@link CombinedEffectResult} is a {@link CombinedEffectResultInSuccessState} */ export const isCombinedEffectResultInSuccessState = ( cer: CombinedEffectResult, ): cer is CombinedEffectResultInSuccessState => isNotEffectError(cer.result); /** * Type representing a {@link CombinedEffectResult} in it's success state (non-pending, hece completed effect) * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect */ export type CombinedEffectResultInCompletedSuccessState = { currentInput: Input | NoValueType; result: Result; resultInput: Input | NoValueType; resultPending: false; }; /** * Typeguard to check if a {@link CombinedEffectResult} is a {@link CombinedEffectResultInCompletedSuccessState} */ export const isCombinedEffectResultInCompletedSuccessState = ( cer: CombinedEffectResult, ): cer is CombinedEffectResultInCompletedSuccessState => isNotEffectError(cer.result) && !cer.resultPending; /** * Value-type for result events produced by {@link EffectSignals}. * In case the effect completes after one result, two reult events will * be dispatched, one with completed false and one with completed true. * This is to handle cases where the effect might send multiple results * before completing. Thus, if an effect never completes, all result events * will have completed false. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectResultEvent = { /** the effect result */ result: SafeEffectResult; /** the effect input that lead to the result */ resultInput: Input; /** the input of the previous completed result, or `NO_VALUE` */ // previousInput: Input | NoValueType; /** the previous completed result, or `NO_VALUE` */ // previousResult: SafeEffectResult | NoValueType; /** has the effect for the given resultInput completed */ completed: boolean; }; /** * Value-type for completed result events produced by {@link EffectSignals}. * In contrast to {@link EffectResultEvent}, events with this type are only dispatched, * if the effect has completed, hence it will never be fired for effects that never complete. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectCompletedResultEvent = Omit< EffectResultEvent, 'completed' > & { completed: true }; /** * Typeguard to check if a {@link EffectResultEvent} is a {@link EffectCompletedResultEvent} */ export const isCompletedResultEvent = ( value: EffectCompletedResultEvent | EffectResultEvent, ): value is EffectCompletedResultEvent => value.completed; export type EffectCompletedResultEventInSuccessState = { result: Result; resultInput: Input; completed: true; }; export type EffectCompletedResultEventInErrorState = { result: ToEffectError | EffectError; resultInput: Input; completed: true; }; export const isCompletedResultEventInSuccessState = ( value: EffectCompletedResultEvent, ): value is EffectCompletedResultEventInSuccessState => isNotEffectError(value.result); export const isCompletedResultEventInErrorState = ( value: EffectCompletedResultEvent, ): value is EffectCompletedResultEventInErrorState => isEffectError(value.result); /** * Type specifying the input {@link EffectSignals} (the corresponding signal-sources are NOT added to the store * by the EffectSignals-setup, but by whoever uses the signals, e.g. by extendSetup or fmap or just using dispatch). * The {@link EffectSignalsFactory} gives you the guarantee, that invalidate-events are NOT missed, even while * the combined-behavior is not subscribed. * * @template Input - specifies the input type for the effect */ export type EffectInputSignals = { /** Behavior being consumed by EffectSignals as input (see {@link EffectConfiguration} on how to configure your factory to subscribe the corresponding behavior eagerly) */ input: DerivedId; /** Event that can be dispatched to trigger re-evaluation of the current input under the given effect */ invalidate: EventId; /** * Event that can be dispatched to trigger the given effect. * This event has only meaning, if `withTrigger` is configured (see `EffectConfiguration`), * else dispatching it is a no-op. */ trigger: EventId; }; /** * Type specifying the output {@link EffectSignals} (signals produced by `EffectSignals`). * The {@link EffectSignalsFactory} takes care that subscribing the result-events keeps * the effect itself lazy (hence only subscribing the combined behavior will subscribe the effect itself)! * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectOutputSignals = { /** Produced combined effect result behavior */ combined: DerivedId>; /** Produced success events */ results: EventId>; /** Produced success events */ completedResults: EventId>; }; /** * Type specifying the effect-type of {@link EffectSignals} (the corresponding effects are NOT added to the store * by the EffectSignals-setup, but by whoever uses the signals, e.g. by `useExistingEffect` or via extended configuration plus `extendSetup`). * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectFactoryEffects = { id: EffectId; }; /** * This type specifies generic effect signals. `EffectSignals` generically handle side-effects (hence, are an abstraction over side-effects). * They fulfill the following requirements: * ```markdown * 1.) The produced CombinedEffectResult behavior must be lazy, hence, as long as it is not subscribed, * no effect will be triggered (so subscribing just results, or completedResults will not trigger the effect). * 2.) Unhandled effect errors are caught and will lead to an EffectError. * 3.) In addition to the combined-behavior, also event-streams for EffectResultEvent and EffectCompletedResultEvent are provided. This is important * in cases where e.g. an effect success should be used to trigger something else (e.g. close a popup), but you cannot use the result * behavior, because it would mean to always subscribe the result. In contrast, subscription of the an rsult-event-stream will NOT * subscribe the effect. * ``` * * See the documentation for {@link EffectConfiguration} for further configuration of `EffectSignals`. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectSignals = Signals< EffectInputSignals, EffectOutputSignals, EffectFactoryEffects >; /** * This type specifies the type of the argument to {@link EffectSignalsBuild}, hence the configuration of {@link EffectSignals}. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectConfiguration = { /** Function used to determine whether a new input equals the previous one. Defaults to strict equals (`a === b`) */ effectInputEquals?: (a: Input, b: Input) => boolean; /** Defaults to false. If true, the effect will only be performed in case a trigger event is received (else, whenever the input changes) */ withTrigger?: boolean; /** If defined, this function will be used to determine an initial result for the result behavior */ initialResultGetter?: () => Result; /** If defined and `>0`, then it will be used as milliseconds to debounce new input to the effect (please DON'T debounce the input signal yourself, because that would debounce before trigger and/or input equals!) */ effectDebounceTime?: number; /** Function to wrap the effect defined by effectId with a custom `Effect` */ wrappedEffectGetter?: (effect: Effect) => Effect; /** Specifies whether the input behavior should be subscribed eagerly (defaults to false) */ eagerInputSubscription?: boolean; /** Optional string to be used as argument to calls of `getBehaviorId` and `getEventId` */ nameExtension?: string; }; /** * Type specifying the {@link SignalsBuild} function for {@link EffectSignals}, hence a function taking a {@link EffectConfiguration} and producing {@link EffectSignals}. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect * @param {EffectConfiguration} config - the configuration for the `EffectSignals` */ export type EffectSignalsBuild = ( config: EffectConfiguration, ) => EffectSignals; const getInputSignalIds = (nameExtension?: string): EffectInputSignals => ({ input: getDerivedId(`${nameExtension ?? ''}_input`), invalidate: getEventId(`${nameExtension ?? ''}_invalidate`), trigger: getEventId(`${nameExtension ?? ''}_trigger`), }); const getOutputSignalIds = ( nameExtension?: string, ): EffectOutputSignals => ({ combined: getDerivedId>( `${nameExtension ?? ''}_combined`, ), results: getEventId>(`${nameExtension ?? ''}_results`), completedResults: getEventId>( `${nameExtension ?? ''}_completedResults`, ), }); const NO_VALUE_TRIGGERED_INPUT = '$RXS_INTERNAL_NV_TI$'; type NoValueTriggeredInput = typeof NO_VALUE_TRIGGERED_INPUT; type InternalEffectResult = { result: SafeEffectResult; completed: boolean; }; type InternalResultType = { result: R | NoValueType; resultInput: Input | NoValueType; resultToken: object | null; }; const getIsNewInput = (effectInputEquals: (a: Input, b: Input) => boolean) => ([input, resultState, token]: [ Input, InternalResultType>, object | null, Input | NoValueTriggeredInput, ]): boolean => token !== resultState.resultToken || isNoValueType(resultState.resultInput) || !effectInputEquals(input, resultState.resultInput); const getEffectBuilder: EffectSignalsBuild = ( config: EffectConfiguration, ): EffectSignals => { const effectId = getEffectId(); const wrappedResultEffect = ( input: IT, args: { store: Store; previousInput: IT | NoValueType; previousResult: SafeEffectResult | NoValueType; }, ) => args.store.getEffect(effectId).pipe( take(1), switchMap(effect => { try { const wrappedEffect = config.wrappedEffectGetter ? config.wrappedEffectGetter(effect) : effect; return wrappedEffect(input, args); } catch (error) { return throwError(() => error); } }), ); const internalResultEffect = ( input: IT, args: { store: Store; previousInput: IT | NoValueType; previousResult: SafeEffectResult | NoValueType; }, ): Observable> => new Observable>(subscriber => { let currentResult: EffectResult | NoValueType = NO_VALUE; const subscription = wrappedResultEffect(input, args).subscribe({ next: result => { currentResult = result; subscriber.next({ result, completed: false, }); }, complete: () => { if (isNotNoValueType(currentResult)) { subscriber.next({ result: currentResult, completed: true, }); } subscriber.complete(); currentResult = NO_VALUE; }, error: e => { subscriber.next({ result: toEffectError({ unhandledError: e, }), completed: true, }); subscriber.complete(); currentResult = NO_VALUE; }, }); return () => { subscription.unsubscribe(); currentResult = NO_VALUE; }; }); const effectInputEquals = config.effectInputEquals ?? ((a, b) => a === b); const isNewInput = getIsNewInput(effectInputEquals); const inIds = getInputSignalIds(config.nameExtension); const outIds = getOutputSignalIds(config.nameExtension); const setup = (store: Store) => { const invalidateTokenBehavior = getStateId(); store.addState(invalidateTokenBehavior, null); store.addReducer(invalidateTokenBehavior, inIds.invalidate, () => ({})); const internalInput = config.eagerInputSubscription === true ? getStateId() : inIds.input; if (internalInput !== inIds.input) { store.connect(inIds.input, internalInput); } const resultEvent = getEventId>>(); const resultBehavior = getDerivedId>>(); const initialResult = config.initialResultGetter ? config.initialResultGetter() : NO_VALUE; store.addDerivedState(resultBehavior, store.getEventStream(resultEvent), { result: isNotNoValueType(initialResult) ? { result: initialResult, completed: true } : NO_VALUE, resultInput: NO_VALUE, resultToken: null, }); const triggeredInputEvent = getEventId(); const triggeredInputBehavior = getDerivedId(); store.addDerivedState( triggeredInputBehavior, store.getEventStream(triggeredInputEvent), NO_VALUE_TRIGGERED_INPUT, ); store.addEventSource( outIds.completedResults, store.getEventStream(outIds.results).pipe(filter(isCompletedResultEvent)), ); // It is important to setup the combined observable as behavior, // because a simple shareReplay (even with refCount) could create a memory leak!!! const combinedId = getDerivedId< [ IT, InternalResultType>, object | null, IT | NoValueTriggeredInput, ] >(); store.addDerivedState( combinedId, combineLatest([ store.getBehavior(internalInput), store.getBehavior(resultBehavior), store.getBehavior(invalidateTokenBehavior), store.getBehavior(triggeredInputBehavior), ]), ); const combined = store.getBehavior(combinedId); const eventSourceInput = config.effectDebounceTime === undefined || config.effectDebounceTime < 1 ? combined : combined.pipe(debounceTime(config.effectDebounceTime)); store.add3TypedEventSource( resultEvent, triggeredInputEvent, outIds.results, eventSourceInput.pipe( filter(isNewInput), switchMap( ([input, resultState, token, triggeredInput]: [ IT, InternalResultType>, object | null, IT | NoValueTriggeredInput, ]) => config.withTrigger && input !== triggeredInput ? store.getEventStream(inIds.trigger).pipe( map(() => ({ type: triggeredInputEvent, event: input, })), ) : internalResultEffect(input, { store, previousInput: resultState.resultInput, previousResult: isNotNoValueType(resultState.result) ? resultState.result.result : NO_VALUE, }).pipe( switchMap((result: InternalEffectResult) => of( { type: resultEvent, event: { // InternalResultType> result, resultInput: input, resultToken: token, }, }, { type: outIds.results, event: { // EffectResultEvent result: result.result, resultInput: input, // previousInput: resultState.resultInput, // previousResult: isNotNoValueType(resultState.result) // ? resultState.result.result // : NO_VALUE, completed: result.completed, }, }, ), ), ), ), ), resultEvent, ); const getIsPending = config.withTrigger ? ([input, resultState, token, triggeredInput]: [ IT, InternalResultType>, object | null, IT | NoValueTriggeredInput, ]) => input === triggeredInput && (token !== resultState.resultToken || resultState.resultInput === NO_VALUE || !effectInputEquals(input, resultState.resultInput) || (isNotNoValueType(resultState.result) && !resultState.result.completed)) : ([input, resultState, token]: [ IT, InternalResultType>, object | null, IT | NoValueTriggeredInput, ]) => token !== resultState.resultToken || resultState.resultInput === NO_VALUE || !effectInputEquals(input, resultState.resultInput) || (isNotNoValueType(resultState.result) && !resultState.result.completed); store.addDerivedState( outIds.combined, combined.pipe( map(([input, resultState, token, triggeredInput]) => getIsPending([input, resultState, token, triggeredInput]) ? { currentInput: input, result: isNotNoValueType(resultState.result) ? resultState.result.result : NO_VALUE, resultInput: resultState.resultInput, resultPending: true, } : { currentInput: input, result: isNotNoValueType(resultState.result) ? resultState.result.result : NO_VALUE, resultInput: resultState.resultInput, resultPending: false, }, ), ), config.initialResultGetter ? { currentInput: NO_VALUE, result: config.initialResultGetter(), resultInput: NO_VALUE, resultPending: false, } : NO_VALUE, ); }; return { setup, input: inIds, output: outIds, effects: { id: effectId, }, }; }; /** * This type specifies a {@link SignalsFactory} wrapping {@link EffectSignals}. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect */ export type EffectSignalsFactory = SignalsFactory< EffectInputSignals, EffectOutputSignals, EffectConfiguration, EffectFactoryEffects >; /** * This function creates an {@link EffectSignalsFactory}. * * @template Input - specifies the input type for the effect * @template Result - specifies the result type of the effect * @template Error - specifies the error type of the effect * @returns {EffectSignalsFactory} */ export const getEffectSignalsFactory = (): EffectSignalsFactory< Input, Result, Error > => new SignalsFactory< EffectInputSignals, EffectOutputSignals, EffectConfiguration, EffectFactoryEffects >(getEffectBuilder);