import { isFunction, isString, resolve } from '@aurelia/kernel'; import { connectable, IObserverLocatorBasedConnectable, type IObserverRecord } from './connectable'; import { enterConnectable, exitConnectable } from './connectable-switcher'; import { IObserverLocator } from './observer-locator'; import { rtCreateInterface } from './utilities'; import { createMappedError, ErrorNames } from './errors'; import type { ICollectionSubscriber, IConnectable, ISubscriber } from './interfaces'; export interface IObservation { /** * Run an effect function an track the dependencies inside it, * to re-run whenever a dependency has changed */ run(fn: EffectRunFunc): IEffect; /** * Run a expression based observer and call the callback whenever the value has changed * * Use options.immediate to indicate whether the callback should be called immediately on init */ watch( obj: object, expression: string, callback: (value: R, oldValue: R | undefined) => unknown, options?: IWatchOptions, ): IEffect; /** * Run a getter based on the given object as its first parameter and track the dependencies via this getter, * to call the callback whenever the value has changed */ watch( obj: T, getter: (obj: T, watcher: IConnectable) => R, callback: (value: R, oldValue: R | undefined) => unknown, options?: IWatchOptions, ): IEffect; } export const IObservation = /*@__PURE__*/rtCreateInterface('IObservation', x => x.singleton(Observation)); export interface IWatchOptions { /** * Indicates whether the callback of a watch should be immediately called on init */ immediate?: boolean; } export class Observation implements IObservation { /** @internal */ private readonly oL = resolve(IObserverLocator); public run(fn: EffectRunFunc): IEffect { const effect = new RunEffect(this.oL, fn); // todo: batch effect run after it's in effect.run(); return effect; } public watch( obj: T, getter: (obj: T, watcher: IConnectable) => R, callback: (value: R, oldValue: R | undefined) => unknown, options?: IWatchOptions, ): IEffect; public watch( obj: object, expression: string, callback: (value: R, oldValue: R | undefined) => unknown, options?: IWatchOptions, ): IEffect; public watch( obj: T, getterOrExpression: string | ((obj: T, watcher: IConnectable) => R), callback: (value: R, oldValue: R | undefined) => unknown, options?: IWatchOptions, ): IEffect { return this._doWatch(obj, getterOrExpression, callback, options); } /** @internal */ private _doWatch( obj: T, expressionOrGetter: string | ((obj: T, watcher: IConnectable) => R), callback: (value: R, oldValue: R | undefined) => unknown, options?: IWatchOptions ): IEffect { // eslint-disable-next-line no-undef-init let $oldValue: R | undefined = undefined; let stopped = false; let cleanupTask: (() => void) | undefined; const handleChange = (newValue: unknown, oldValue: unknown) => { cleanupTask?.(); cleanupTask = void 0; const result = callback(newValue as R, $oldValue = oldValue as R); if (isFunction(result)) { cleanupTask = result as NonNullable; } else { // throw or ignore? } }; const observer = isString(expressionOrGetter) ? this.oL.getExpressionObserver(obj, expressionOrGetter) : this.oL.getObserver(obj, expressionOrGetter); const handler: ISubscriber = { handleChange }; const run = () => { if (stopped) { return; } handleChange(observer.getValue(), $oldValue); }; const stop = () => { if (stopped) { throw createMappedError(ErrorNames.stopping_a_stopped_effect); } stopped = true; observer.unsubscribe(handler); cleanupTask?.(); cleanupTask = void 0; }; observer.subscribe(handler); if (options?.immediate !== false) { run(); } return { run, stop }; } } // eslint-disable-next-line @typescript-eslint/no-unused-vars function testObservationWatch(a: IObservation) { a.watch({ b: 5 }, o => o.b + 1, v => v.toFixed()); a.watch({ b: { c: '' } }, o => o.b.c === '', v => v); a.watch({ b: { c: { d: 5 }} }, 'b.c.d', v => v.toFixed()); } export type EffectCleanupFunc = () => void; export type EffectRunFunc = (this: IConnectable, runner: IConnectable) => EffectCleanupFunc | void; export interface IEffect { run(): void; stop(): void; } interface RunEffect extends IConnectable {} class RunEffect implements IEffect, IObserverLocatorBasedConnectable, ISubscriber, ICollectionSubscriber { static { connectable(RunEffect, null!); } public readonly obs!: IObserverRecord; // to configure this, potentially a 2nd parameter is needed for run public maxRunCount: number = 10; private queued: boolean = false; private running: boolean = false; private runCount: number = 0; private stopped: boolean = false; /** @internal */ private _cleanupTask: (() => void) | undefined = undefined; public constructor( public readonly oL: IObserverLocator, public readonly fn: EffectRunFunc, ) { } public handleChange(): void { this.queued = true; this.run(); } public handleCollectionChange(): void { this.queued = true; this.run(); } public run = () => { if (this.running || this.stopped) { return; } ++this.runCount; this.running = true; this.queued = false; ++this.obs.version; try { this._cleanupTask?.call(void 0); enterConnectable(this); this._cleanupTask = this.fn(this) as EffectCleanupFunc; } finally { this.obs.clear(); this.running = false; exitConnectable(this); } // when doing this.fn(this), there's a chance that it has recursive effect // continue to run for a certain number before bailing // whenever there's a dependency change while running, this.queued will be true // so we use it as an indicator to continue to run the effect if (this.queued) { if (this.runCount > this.maxRunCount) { this.runCount = 0; throw createMappedError(ErrorNames.effect_maximum_recursion_reached); } this.run(); } else { this.runCount = 0; } }; public stop = () => { if (this.stopped) { throw createMappedError(ErrorNames.stopping_a_stopped_effect); } this.stopped = true; this.obs.clearAll(); this._cleanupTask?.call(void 0); this._cleanupTask = void 0; }; }