packages/state/src/state-binding.ts
import { IDisposable, type IServiceLocator, type Writable } from '@aurelia/kernel';
import { ITask, QueueTaskOptions, TaskQueue } from '@aurelia/platform';
import {
connectable,
type IAccessor,
type IObserverLocator,
AccessorType,
type IObserverLocatorBasedConnectable,
ISubscriber
} from '@aurelia/runtime';
import {
BindingMode,
type Scope,
type IOverrideContext,
type IBindingController,
mixinAstEvaluator,
mixingBindingLimited,
State,
astEvaluate,
IAstEvaluator,
IBinding,
} from '@aurelia/runtime-html';
import {
IStore,
type IStoreSubscriber
} from './interfaces';
import { createStateBindingScope } from './state-utilities';
import { IsBindingBehavior } from '@aurelia/expression-parser';
const atLayout = AccessorType.Layout;
const stateActivating = State.activating;
/**
* A binding that handles the connection of the global state to a property of a target object
*/
export interface StateBinding extends IAstEvaluator, IObserverLocatorBasedConnectable, IServiceLocator { }
export class StateBinding implements IBinding, ISubscriber, IStoreSubscriber<object> {
public isBound: boolean = false;
/** @internal */
public readonly oL: IObserverLocator;
/** @internal */
public l: IServiceLocator;
/** @internal */
public _scope?: Scope | undefined;
public ast: IsBindingBehavior;
private readonly target: object;
private readonly targetProperty: PropertyKey;
/** @internal */ private _task: ITask | null = null;
/** @internal */ private readonly _taskQueue: TaskQueue;
/** @internal */ private readonly _store: IStore<object>;
/** @internal */ private _targetObserver!: IAccessor;
/** @internal */ private _value: unknown = void 0;
/** @internal */ private _sub?: IDisposable | Unsubscribable | (() => void) = void 0;
/** @internal */ private _updateCount = 0;
/** @internal */ private readonly _controller: IBindingController;
// see Listener binding for explanation
/** @internal */
public readonly boundFn = false;
public mode: BindingMode = BindingMode.toView;
public constructor(
controller: IBindingController,
locator: IServiceLocator,
observerLocator: IObserverLocator,
taskQueue: TaskQueue,
ast: IsBindingBehavior,
target: object,
prop: PropertyKey,
store: IStore<object>,
) {
this._controller = controller;
this.l = locator;
this._taskQueue = taskQueue;
this._store = store;
this.oL = observerLocator;
this.ast = ast;
this.target = target;
this.targetProperty = prop;
}
public updateTarget(value: unknown) {
const targetAccessor = this._targetObserver;
const target = this.target;
const prop = this.targetProperty;
const updateCount = this._updateCount++;
const isCurrentValue = () => updateCount === this._updateCount - 1;
this._unsub();
if (isSubscribable(value)) {
this._sub = value.subscribe($value => {
if (isCurrentValue()) {
targetAccessor.setValue($value, target, prop);
}
});
return;
}
if (value instanceof Promise) {
void value.then($value => {
if (isCurrentValue()) {
targetAccessor.setValue($value, target, prop);
}
}, () => {/* todo: don't ignore */});
return;
}
targetAccessor.setValue(value, target, prop);
}
public bind(_scope: Scope): void {
if (this.isBound) {
return;
}
this._targetObserver = this.oL.getAccessor(this.target, this.targetProperty);
this._store.subscribe(this);
this.updateTarget(this._value = astEvaluate(
this.ast,
this._scope = createStateBindingScope(this._store.getState(), _scope),
this,
this.mode > BindingMode.oneTime ? this : null),
);
this.isBound = true;
}
public unbind(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this._unsub();
// also disregard incoming future value of promise resolution if any
this._updateCount++;
this._scope = void 0;
this._task?.cancel();
this._task = null;
this._store.unsubscribe(this);
}
public handleChange(newValue: unknown): void {
if (!this.isBound) {
return;
}
// Alpha: during bind a simple strategy for bind is always flush immediately
// todo:
// (1). determine whether this should be the behavior
// (2). if not, then fix tests to reflect the changes/platform to properly yield all with aurelia.start()
const shouldQueueFlush = this._controller.state !== stateActivating && (this._targetObserver.type & atLayout) > 0;
const obsRecord = this.obs;
obsRecord.version++;
newValue = astEvaluate(this.ast, this._scope!, this, this);
obsRecord.clear();
let task: ITask | null;
if (shouldQueueFlush) {
// Queue the new one before canceling the old one, to prevent early yield
task = this._task;
this._task = this._taskQueue.queueTask(() => {
this.updateTarget(newValue);
this._task = null;
}, updateTaskOpts);
task?.cancel();
task = null;
} else {
this.updateTarget(newValue);
}
}
public handleStateChange(): void {
if (!this.isBound) {
return;
}
const state = this._store.getState();
const _scope = this._scope!;
const overrideContext = _scope.overrideContext as Writable<IOverrideContext>;
_scope.bindingContext = overrideContext.bindingContext = overrideContext.$state = state;
const value = astEvaluate(
this.ast,
_scope,
this,
this.mode > BindingMode.oneTime ? this : null
);
const shouldQueueFlush = this._controller.state !== stateActivating && (this._targetObserver.type & atLayout) > 0;
if (value === this._value) {
return;
}
this._value = value;
let task: ITask | null = null;
if (shouldQueueFlush) {
// Queue the new one before canceling the old one, to prevent early yield
task = this._task;
this._task = this._taskQueue.queueTask(() => {
this.updateTarget(value);
this._task = null;
}, updateTaskOpts);
task?.cancel();
} else {
this.updateTarget(this._value);
}
}
/** @internal */
private _unsub() {
if (typeof this._sub === 'function') {
this._sub();
} else if (this._sub !== void 0) {
(this._sub as IDisposable).dispose?.();
(this._sub as Unsubscribable).unsubscribe?.();
}
this._sub = void 0;
}
}
function isSubscribable(v: unknown): v is SubscribableValue {
return v instanceof Object && 'subscribe' in (v as SubscribableValue);
}
type SubscribableValue = {
subscribe(cb: (res: unknown) => void): IDisposable | Unsubscribable | (() => void);
};
type Unsubscribable = {
unsubscribe(): void;
};
const updateTaskOpts: QueueTaskOptions = {
reusable: false,
preempt: true,
};
connectable(StateBinding, null!);
mixinAstEvaluator(true)(StateBinding);
mixingBindingLimited(StateBinding, () => 'updateTarget');