import { AccessorType, IAccessor, ISubscriberCollection, atObserver } from './interfaces'; import { rtSafeString, rtDef } from './utilities'; import { currentConnectable } from './connectable-switcher'; import { areEqual, emptyObject, isFunction, type Constructable, type IIndexable } from '@aurelia/kernel'; import type { InterceptorFunc, IObservable } from './interfaces'; import type { ObservableGetter } from './observer-locator'; import { subscriberCollection } from './subscriber-collection'; import { ErrorNames, createMappedError } from './errors'; import { getObserverLookup } from './observation-utils'; export interface IObservableDefinition { name?: PropertyKey; callback?: PropertyKey; set?: InterceptorFunc; } type FieldInitializer = (this: TFThis, initialValue: TValue) => TValue; type ObservableFieldDecorator = (target: undefined, context: ClassFieldDecoratorContext) => FieldInitializer; type ObservableClassDecorator = (target: TCThis, context: ClassDecoratorContext) => void; export const observable = /*@__PURE__*/(() => { const noValue: unknown = {}; // for // class { // @observable prop // } function observable(target: undefined, context: ClassFieldDecoratorContext): FieldInitializer; // for // @observable({...}) // class {} // and // class { // @observable({...}) prop // } function observable(config: IObservableDefinition): (target: TCThis | undefined, context: ClassDecoratorContext | ClassFieldDecoratorContext) => FieldInitializer | void; // for // @observable('') class {} // @observable(5) class {} // @observable(Symbol()) class {} function observable(key: PropertyKey): ObservableClassDecorator; // for: // class { // @observable() prop // } function observable(): ObservableFieldDecorator; // impl, wont be seen function observable(targetOrConfig?: undefined | IObservableDefinition | PropertyKey, context?: ClassFieldDecoratorContext): ObservableClassDecorator | ObservableFieldDecorator | FieldInitializer { if (!SetterNotifier.mixed) { SetterNotifier.mixed = true; subscriberCollection(SetterNotifier, null!); } let isClassDecorator = false; let config: IObservableDefinition; if (typeof targetOrConfig === 'object') { config = targetOrConfig; } else if (targetOrConfig != null) { config = { name: targetOrConfig }; isClassDecorator = true; } else { config = emptyObject; } // case: @observable() prop if (arguments.length === 0) { return function (target: unknown, context: DecoratorContext) { if (context.kind !== 'field') throw createMappedError(ErrorNames.invalid_observable_decorator_usage); return createFieldInitializer(context); }; } // case: @observable prop if (context?.kind === 'field') return createFieldInitializer(context); // case: @observable(PropertyKey) class if (isClassDecorator) { return function (target: TCThis, context: ClassDecoratorContext) { createDescriptor(target, config.name!, () => noValue, true); }; } // case: @observable({...}) class | @observable({...}) prop return function (target: Constructable | undefined, context: ClassFieldDecoratorContext | ClassDecoratorContext) { switch (context.kind) { case 'field': return createFieldInitializer(context); case 'class': return createDescriptor(target, config.name!, () => noValue, true); default: throw createMappedError(ErrorNames.invalid_observable_decorator_usage); } }; function createFieldInitializer(context: ClassFieldDecoratorContext): FieldInitializer { let $initialValue: TValue; context.addInitializer(function (this: unknown) { createDescriptor(this, context.name, () => $initialValue, false); }); return function (this: TFThis, initialValue: TValue) { return $initialValue = initialValue; }; } function createDescriptor(target: unknown, property: PropertyKey, initialValue: () => unknown, targetIsClass: boolean): void { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/strict-boolean-expressions const callback = config.callback || `${rtSafeString(property)}Changed`; const $set = config.set; const observableGetter: ObservableGetter = function (this: IObservable) { const notifier = getNotifier(this, property, callback, initialValue, $set); currentConnectable()?.subscribeTo(notifier); return notifier.getValue(); }; observableGetter.getObserver = function (obj: IObservable) { return getNotifier(obj, property, callback, initialValue, $set); }; const descriptor = { enumerable: true, configurable: true, get: observableGetter, set(this: IObservable, newValue: TValue) { getNotifier(this, property, callback, initialValue, $set).setValue(newValue); } }; if (targetIsClass) rtDef((target as Constructable).prototype as object, property, descriptor); else rtDef(target as object, property, descriptor); } } function getNotifier( obj: IObservable, key: PropertyKey, callbackKey: PropertyKey, initialValue: () => unknown, set: InterceptorFunc | undefined, ): SetterNotifier { const lookup = getObserverLookup(obj); let notifier = lookup[key as string] as SetterNotifier; if (notifier == null) { const $initialValue = initialValue(); notifier = new SetterNotifier(obj, callbackKey, set, $initialValue === noValue ? void 0 : $initialValue); lookup[key as string] = notifier; } return notifier; } type ChangeHandlerCallback = (this: object, value: unknown, oldValue: unknown) => void; interface SetterNotifier extends IAccessor, ISubscriberCollection { } class SetterNotifier implements IAccessor { public static mixed = false; public readonly type: AccessorType = atObserver; /** @internal */ private _value: unknown = void 0; /** @internal */ private _oldValue: unknown = void 0; /** @internal */ private readonly cb?: ChangeHandlerCallback; /** @internal */ private readonly _obj: object; /** @internal */ private readonly _setter: InterceptorFunc | undefined; /** @internal */ private readonly _hasSetter: boolean; public constructor( obj: object, callbackKey: PropertyKey, set: InterceptorFunc | undefined, initialValue: unknown, ) { this._obj = obj; this._setter = set; this._hasSetter = isFunction(set); const callback = (obj as IIndexable)[callbackKey as string]; this.cb = isFunction(callback) ? callback as ChangeHandlerCallback : void 0; this._value = initialValue; } public getValue(): unknown { return this._value; } public setValue(value: unknown): void { if (this._hasSetter) { value = this._setter!(value); } if (!areEqual(value, this._value)) { this._oldValue = this._value; this._value = value; this.subs.notifyDirty(); this.subs.notify(this._value, this._oldValue); // if the value has been changed during the notify, don't call the callback // it's the job of the last .setValue() to call the callback if (areEqual(value, this._value)) { this.cb?.call(this._obj, this._value, this._oldValue); } } } } /* | typescript | babel ----------|------------------|------------------------- property | config | config w/parens | target, key | target, key, descriptor ----------|------------------|------------------------- property | target, key | target, key, descriptor no parens | n/a | n/a ----------|------------------|------------------------- class | config | config | target | target */ return observable; })();