import type { ValueObserverOptions } from './value-observer.js' import { ValueObserver } from './value-observer.js' /** Thrown by {@link ObservableValue.getValue} / {@link ObservableValue.setValue} / {@link ObservableValue.subscribe} after the value has been disposed. */ export class ObservableAlreadyDisposedError extends Error { constructor() { super('Observable already disposed') } } export type ValueChangeCallback = (next: T) => void | PromiseLike export type ObservableValueOptions = { /** * Returns `true` to treat `nextValue` as a change (and notify observers), * `false` to skip. Defaults to `!==` reference equality. */ compare: (lastValue: T, nextValue: T) => boolean /** * Called when an observer callback or filter throws (sync) or rejects * (async). Remaining observers are still notified. Defaults to logging * via `console.error`. */ onError: (options: { error: unknown; observer: ValueObserver }) => void } const defaultComparer = (a: T, b: T) => a !== b /** * Disposable holder for a single value with subscription support. * * @example * ```ts * using(new ObservableValue(0), (value) => { * const observer = value.subscribe((next) => console.log('changed:', next)) * value.setValue(42) * observer[Symbol.dispose]() * }) * ``` */ export class ObservableValue implements Disposable { public get isDisposed(): boolean { return this._isDisposed } private _isDisposed = false public [Symbol.dispose]() { this.observers.clear() this._isDisposed = true // @ts-expect-error getting currentValue after disposing is not allowed this.currentValue = null } private observers: Set> = new Set() private currentValue: T /** * Subscribes `callback` to value changes. Dispose the returned * {@link ValueObserver} (or call {@link ObservableValue.unsubscribe}) to * stop receiving notifications. Throws {@link ObservableAlreadyDisposedError} * after disposal. */ public subscribe(callback: ValueChangeCallback, options?: ValueObserverOptions) { if (this._isDisposed) { throw new ObservableAlreadyDisposedError() } const observer = new ValueObserver(this, callback, options) this.observers.add(observer) return observer } public unsubscribe(observer: ValueObserver) { return this.observers.delete(observer) } /** Throws {@link ObservableAlreadyDisposedError} after disposal. */ public getValue(): T { if (this._isDisposed) { throw new ObservableAlreadyDisposedError() } return this.currentValue } /** * Sets the value. Observers are notified only when * {@link ObservableValueOptions.compare} reports a change. Throws * {@link ObservableAlreadyDisposedError} after disposal. */ public setValue(newValue: T) { if (this._isDisposed) { throw new ObservableAlreadyDisposedError() } if (this.options.compare(this.currentValue, newValue)) { this.currentValue = newValue this.observers.forEach((observer) => { try { if (observer.options?.filter?.(this.currentValue, newValue) !== false) { const result = observer.callback(newValue) if (result && typeof result.then === 'function') { result.then(undefined, (error: unknown) => { this.options.onError({ error, observer }) }) } } } catch (error) { this.options.onError({ error, observer }) } }) } } public getObservers() { return [...this.observers] as ReadonlyArray> } private readonly options: ObservableValueOptions constructor(initialValue: T, options?: Partial>) { this.options = { compare: defaultComparer, onError: ({ error }) => console.error('Error in ObservableValue observer', error), ...options, } this.currentValue = initialValue } }