import { Injectable, OnDestroy } from '@angular/core'; const symbolObservable = (typeof Symbol === 'function' && Symbol.observable) || '@@observable'; /** * A callback invoked when a store value changes. It is called with the latest value of a given store. */ export type SubscriberFunction<T> = (value: T) => void; /** * A partial [observer](https://github.com/tc39/proposal-observable#api) notified when a store value changes. A store will call the `next` method every time the store's state is changing. */ export interface SubscriberObject<T> { next: SubscriberFunction<T>; invalidate: () => void; } /** * Expresses interest in store value changes over time. It can be either: * - a callback function: {@link SubscriberFunction}; * - a partial observer: {@link SubscriberObject}. */ export type Subscriber<T> = SubscriberFunction<T> | Partial<SubscriberObject<T>> | null | undefined; /** * A function to unsubscribe from value change notifications. */ export type UnsubscribeFunction = () => void; /** * An object with the `unsubscribe` method. * Subscribable stores might choose to return such object instead of directly returning {@link UnsubscribeFunction} from a subscription call. */ export interface UnsubscribeObject { /** * A method that acts as the {@link UnsubscribeFunction}. */ unsubscribe: UnsubscribeFunction; } export type Unsubscriber = UnsubscribeObject | UnsubscribeFunction; /** * Represents a store accepting registrations (subscribers) and "pushing" notifications on each and every store value change. */ export interface SubscribableStore<T> { /** * A method that makes it possible to register "interest" in store value changes over time. * It is called each and every time the store's value changes. * A registered subscriber is notified synchronously with the latest store value. * * @param subscriber - a subscriber in a form of a {@link SubscriberFunction} or a {@link SubscriberObject}. Returns a {@link Unsubscriber} (function or object with the `unsubscribe` method) that can be used to unregister and stop receiving notifications of store value changes. * @returns The {@link UnsubscribeFunction} or {@link UnsubscribeObject} that can be used to unsubscribe (stop state change notifications). */ subscribe(subscriber: Subscriber<T>): Unsubscriber; } /** * This interface augments the base {@link SubscribableStore} interface with the Angular-specific `OnDestroy` callback. The {@link Readable} stores can be registered in the Angular DI container and will automatically discard all the subscription when a given store is destroyed. */ export interface Readable<T> extends SubscribableStore<T>, OnDestroy { subscribe(subscriber: Subscriber<T>): UnsubscribeFunction & UnsubscribeObject; } /** * A function that can be used to update store's value. This function is called with the current value and should return new store value. */ export type Updater<T> = (value: T) => T; /** * Builds on top of {@link Readable} and represents a store that can be manipulated from "outside": anyone with a reference to writable store can either update or completely replace state of a given store. * * ```typescript * // reset counter's store value to 0 by using the {@link Writable.set} method * counterStore.set(0); * * // increment counter's store value by using the {@link Writable.update} method * counterStore.update(currentValue => currentValue + 1); * ``` */ export interface Writable<T> extends Readable<T> { /** * Replaces store's state with the provided value. * @param value - value to be used as the new state of a store. */ set(value: T): void; /** * Updates store's state by using an {@link Updater} function. * @param updater - a function that takes the current state as an argument and returns the new state. */ update(updater: Updater<T>): void; } const noop = () => {}; const bind = <T>(object: T | null | undefined, fnName: keyof T) => { const fn = object ? object[fnName] : null; return typeof fn === 'function' ? fn.bind(object) : noop; }; const toSubscriberObject = <T>(subscriber: Subscriber<T>): SubscriberObject<T> => typeof subscriber === 'function' ? { next: subscriber.bind(null), invalidate: noop } : { next: bind(subscriber, 'next'), invalidate: bind(subscriber, 'invalidate') }; const returnThis = function <T>(this: T): T { return this; }; const asReadable = <T>(store: Store<T>): Readable<T> => ({ subscribe: store.subscribe.bind(store), ngOnDestroy: store.ngOnDestroy.bind(store), [symbolObservable]: returnThis, }); const queue: [SubscriberFunction<any>, any][] = []; function processQueue() { for (const [subscriberFn, value] of queue) { subscriberFn(value); } queue.length = 0; } const callUnsubscribe = (unsubscribe: Unsubscriber) => typeof unsubscribe === 'function' ? unsubscribe() : unsubscribe.unsubscribe(); function notEqual(a: any, b: any): boolean { const tOfA = typeof a; if (tOfA !== 'function' && tOfA !== 'object') { return !Object.is(a, b); } return true; } /** * A utility function to get the current value from a given store. * It works by subscribing to a store, capturing the value (synchronously) and unsubscribing just after. * * @param store - a store from which the current value is retrieved. * * @example * ```typescript * const myStore = writable(1); * console.log(get(myStore)); // logs 1 * ``` */ export function get<T>(store: SubscribableStore<T>): T { let value: T; callUnsubscribe(store.subscribe((v) => (value = v))); return value!; } /** * Base class that can be extended to easily create a custom {@link Readable} store. * * @example * ```typescript * class CounterStore extends Store { * constructor() { * super(1); // initial value * } * * reset() { * this.set(0); * } * * increment() { * this.update(value => value + 1); * } * } * * const store = new CounterStore(1); * * // logs 1 (initial value) upon subscription * const unsubscribe = store.subscribe((value) => { * console.log(value); * }); * store.increment(); // logs 2 * store.reset(); // logs 0 * * unsubscribe(); // stops notifications and corresponding logging * ``` */ @Injectable() export abstract class Store<T> implements Readable<T> { private _subscribers = new Set<SubscriberObject<T>>(); private _cleanupFn: null | Unsubscriber = null; /** * * @param _value - Initial value of the store */ constructor(private _value: T) {} private _start() { this._cleanupFn = this.onUse() || noop; } private _stop() { const cleanupFn = this._cleanupFn; if (cleanupFn) { this._cleanupFn = null; callUnsubscribe(cleanupFn); } } /** * Replaces store's state with the provided value. * Equivalent of {@link Writable.set}, but internal to the store. * * @param value - value to be used as the new state of a store. */ protected set(value: T): void { if (notEqual(this._value, value)) { this._value = value; if (!this._cleanupFn) { // subscriber not yet initialized return; } const needsProcessQueue = queue.length == 0; for (const subscriber of this._subscribers) { subscriber.invalidate(); queue.push([subscriber.next, value]); } if (needsProcessQueue) { processQueue(); } } } /** * Updates store's state by using an {@link Updater} function. * Equivalent of {@link Writable.update}, but internal to the store. * * @param updater - a function that takes the current state as an argument and returns the new state. */ protected update(updater: Updater<T>): void { this.set(updater(this._value)); } /** * Function called when the number of subscribers changes from 0 to 1 * (but not called when the number of subscribers changes from 1 to 2, ...). * If a function is returned, it will be called when the number of subscribers changes from 1 to 0. * * @example * * ```typescript * class CustomStore extends Store { * onUse() { * console.log('Got the fist subscriber!'); * return () => { * console.log('All subscribers are gone...'); * }; * } * } * * const store = new CustomStore(); * const unsubscribe1 = store.subscribe(() => {}); // logs 'Got the fist subscriber!' * const unsubscribe2 = store.subscribe(() => {}); // nothing is logged as we've got one subscriber already * unsubscribe1(); // nothing is logged as we still have one subscriber * unsubscribe2(); // logs 'All subscribers are gone...' * ``` */ protected onUse(): Unsubscriber | void {} /** * Default Implementation of the {@link SubscribableStore.subscribe}, not meant to be overridden. * @param subscriber - see {@link SubscribableStore.subscribe} */ subscribe(subscriber: Subscriber<T>): UnsubscribeFunction & UnsubscribeObject { const subscriberObject = toSubscriberObject(subscriber); this._subscribers.add(subscriberObject); if (this._subscribers.size == 1) { this._start(); } subscriberObject.next(this._value); const unsubscribe = () => { const removed = this._subscribers.delete(subscriberObject); if (removed && this._subscribers.size === 0) { this._stop(); } }; unsubscribe.unsubscribe = unsubscribe; return unsubscribe; } ngOnDestroy(): void { const hasSubscribers = this._subscribers.size > 0; this._subscribers.clear(); if (hasSubscribers) { this._stop(); } } [symbolObservable](): this { return this; } } export interface OnUseArgument<T> { (value: T): void; set: (value: T) => void; update: (updater: Updater<T>) => void; } const noopUnsubscribe = () => {}; noopUnsubscribe.unsubscribe = noopUnsubscribe; /** * A convenience function to create an optimized constant store (i.e. which never changes * its value). It does not keep track of its subscribers. * @param value - value of the store, which will never change */ function constStore<T>(value: T): Readable<T> { return { subscribe(subscriber: Subscriber<T>) { toSubscriberObject(subscriber).next(value); return noopUnsubscribe; }, [symbolObservable]: returnThis, ngOnDestroy: noop, }; } /** * A convenience function to create {@link Readable} store instances. * @param value - Initial value of a readable store. * @param onUseFn - A function called when the number of subscribers changes from 0 to 1 * (but not called when the number of subscribers changes from 1 to 2, ...). * If a function is returned, it will be called when the number of subscribers changes from 1 to 0. * * @example * ```typescript * const clock = readable("00:00", setState => { * const intervalID = setInterval(() => { * const date = new Date(); * setState(`${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`); * }, 1000); * * return () => clearInterval(intervalID); * }); * ``` */ export function readable<T>( value: T, onUseFn: (arg: OnUseArgument<T>) => void | Unsubscriber = noop ): Readable<T> { if (onUseFn === noop) { // special optimized case return constStore(value); } const ReadableStoreWithOnUse = class extends Store<T> { protected onUse() { const setFn = (v: T) => this.set(v); setFn.set = setFn; setFn.update = (updater: Updater<T>) => this.update(updater); return onUseFn(setFn); } }; return asReadable(new ReadableStoreWithOnUse(value)); } @Injectable() class WritableStore<T> extends Store<T> implements Writable<T> { constructor(value: T) { super(value); } set(value: T): void { super.set(value); } update(updater: Updater<T>) { super.update(updater); } } /** * A convenience function to create {@link Writable} store instances. * @param value - initial value of a new writable store. * @param onUseFn - A function called when the number of subscribers changes from 0 to 1 * (but not called when the number of subscribers changes from 1 to 2, ...). * If a function is returned, it will be called when the number of subscribers changes from 1 to 0. * * @example * ```typescript * const x = writable(0); * * x.update(v => v + 1); // increment * x.set(0); // reset back to the default value * ``` */ export function writable<T>( value: T, onUseFn: (arg: OnUseArgument<T>) => void | Unsubscriber = noop ): Writable<T> { const WritableStoreWithOnUse = class extends WritableStore<T> { protected onUse() { const setFn = (v: T) => this.set(v); setFn.set = setFn; setFn.update = (updater: Updater<T>) => this.update(updater); return onUseFn(setFn); } }; const store = new WritableStoreWithOnUse(value); return { ...asReadable(store), set: store.set.bind(store), update: store.update.bind(store), }; } type SubscribableStores = | SubscribableStore<any> | readonly [SubscribableStore<any>, ...SubscribableStore<any>[]]; type SubscribableStoresValues<S> = S extends SubscribableStore<infer T> ? T : { [K in keyof S]: S[K] extends SubscribableStore<infer T> ? T : never }; type SyncDeriveFn<T, S> = (values: SubscribableStoresValues<S>) => T; type AsyncDeriveFn<T, S> = ( values: SubscribableStoresValues<S>, set: OnUseArgument<T> ) => Unsubscriber | void; type DeriveFn<T, S> = SyncDeriveFn<T, S> | AsyncDeriveFn<T, S>; function isSyncDeriveFn<T, S>(fn: DeriveFn<T, S>): fn is SyncDeriveFn<T, S> { return fn.length <= 1; } @Injectable() export abstract class DerivedStore< T, S extends SubscribableStores = SubscribableStores > extends Store<T> { constructor(private _stores: S, initialValue: T) { super(initialValue); } protected onUse(): Unsubscriber | void { let initDone = false; let pending = 0; const stores = this._stores; const isArray = Array.isArray(stores); const storesArr = isArray ? (stores as readonly SubscribableStore<any>[]) : [stores as SubscribableStore<any>]; const dependantValues = new Array(storesArr.length); let cleanupFn: null | Unsubscriber = null; const callCleanup = () => { const fn = cleanupFn; if (fn) { cleanupFn = null; callUnsubscribe(fn); } }; const callDerive = () => { if (initDone && !pending) { callCleanup(); cleanupFn = this.derive(isArray ? dependantValues : dependantValues[0]) || noop; } }; const unsubscribers = storesArr.map((store, idx) => store.subscribe({ next: (v) => { dependantValues[idx] = v; pending &= ~(1 << idx); callDerive(); }, invalidate: () => { pending |= 1 << idx; }, }) ); initDone = true; callDerive(); return () => { callCleanup(); unsubscribers.forEach(callUnsubscribe); }; } protected abstract derive(values: SubscribableStoresValues<S>): Unsubscriber | void; } /** * A convenience function to create a new store with a state computed from the latest values of dependent stores. * Each time the state of one of the dependent stores changes, a provided derive function is called to compute a new, derived state. * * @param stores - a single store or an array of dependent stores * @param deriveFn - a function that is used to compute a new state based on the latest values of dependent stores * * @example * ```typescript * const x$ = writable(2); * const y$ = writable(3); * const sum$ = derived([x$, $y], ([x, y]) => x + y); * * // will log 5 upon subscription * sum$.subscribe((value) => { * console.log(value) * }); * * x$.set(3); // will re-evaluate the `([x, y]) => x + y` function and log 6 as this is the new state of the derived store * ``` */ export function derived<T, S extends SubscribableStores>( stores: S, deriveFn: SyncDeriveFn<T, S> ): Readable<T>; export function derived<T, S extends SubscribableStores>( stores: S, deriveFn: AsyncDeriveFn<T, S>, initialValue: T ): Readable<T>; export function derived<T, S extends SubscribableStores>( stores: S, deriveFn: DeriveFn<T, S>, initialValue?: T ): Readable<T> { const Derived = isSyncDeriveFn(deriveFn) ? class extends DerivedStore<T, S> { protected derive(values: SubscribableStoresValues<S>) { this.set(deriveFn(values)); } } : class extends DerivedStore<T, S> { protected derive(values: SubscribableStoresValues<S>) { const setFn = (v: T) => this.set(v); setFn.set = setFn; setFn.update = (updater: Updater<T>) => this.update(updater); return deriveFn(values, setFn); } }; return asReadable(new Derived(stores, initialValue as any)); }