227 lines
6.1 KiB
TypeScript
227 lines
6.1 KiB
TypeScript
import { batchedUpdater } from "./batchedUpdater";
|
|
import { type Plugin } from "./plugin";
|
|
import { PluginManager } from "./pluginManager";
|
|
|
|
const UNSET = Symbol();
|
|
|
|
const plugins = new PluginManager();
|
|
|
|
const capturedInputFrames: BaseObservable<any>[][] = [];
|
|
let shouldCaptureNextInput = false;
|
|
|
|
let batchedObservables: BaseObservable<any>[] = [];
|
|
let batchDepth = 0;
|
|
|
|
export type Listener<T> = (val: T, prevVal: T) => void;
|
|
export type Unsubscriber = () => void;
|
|
export type Options = { [key: string]: any };
|
|
|
|
export class BaseObservable<T> {
|
|
private _val: T;
|
|
private _prevVal: T | typeof UNSET = UNSET;
|
|
private _options: Options;
|
|
private _inputs: BaseObservable<any>[] = [];
|
|
private _outputs: BaseObservable<any>[] = [];
|
|
private _listeners: Listener<T>[] = [];
|
|
private _attachedToInputs = false;
|
|
|
|
constructor(val: T, options: Options = {}) {
|
|
this._val = val;
|
|
this._options = options;
|
|
plugins.onCreate(this, val);
|
|
}
|
|
|
|
get(): T {
|
|
const capturedInputs = capturedInputFrames[capturedInputFrames.length - 1];
|
|
if (capturedInputs && shouldCaptureNextInput) {
|
|
try {
|
|
shouldCaptureNextInput = false;
|
|
capturedInputs.push(this);
|
|
return this._get();
|
|
} finally {
|
|
shouldCaptureNextInput = true;
|
|
}
|
|
} else {
|
|
return this._get();
|
|
}
|
|
}
|
|
|
|
protected _get(): T {
|
|
const shouldEvaluate = !this._attachedToInputs || this._prevVal !== UNSET;
|
|
return shouldEvaluate ? this._evaluate() : this._val;
|
|
}
|
|
|
|
protected _evaluate(): T {
|
|
return this._val;
|
|
}
|
|
|
|
protected _set(val: T) {
|
|
if (this._val !== val) {
|
|
this._addToBatchRecursively();
|
|
this._val = val;
|
|
}
|
|
}
|
|
|
|
subscribe(listener: Listener<T>): Unsubscriber {
|
|
this._listeners.push(listener);
|
|
this._attachToInputs();
|
|
|
|
let listenerRemoved = false;
|
|
return () => {
|
|
if (!listenerRemoved) {
|
|
listenerRemoved = true;
|
|
this._listeners.splice(this._listeners.indexOf(listener), 1);
|
|
this._detachFromInputs();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use observable.subscribe() instead
|
|
*/
|
|
onChange = this.subscribe;
|
|
|
|
protected onBecomeObserved() {
|
|
// Called when the first listener subscribes to the observable or to one of its outputs
|
|
}
|
|
|
|
protected onBecomeUnobserved() {
|
|
// Called when the last listener unsubscribes from the observable and from all of its outputs
|
|
}
|
|
|
|
getInputs(): BaseObservable<any>[] {
|
|
return this._inputs;
|
|
}
|
|
|
|
getOptions<O extends Options = Options>(): O {
|
|
return this._options as O;
|
|
}
|
|
|
|
withOptions<O extends Options = Options>(options: Partial<O>): this {
|
|
this._options = { ...this._options, ...options };
|
|
return this;
|
|
}
|
|
|
|
protected static _captureInputs<T>(block: () => T): BaseObservable<any>[] {
|
|
try {
|
|
const capturedInputs: any[] = [];
|
|
capturedInputFrames.push(capturedInputs);
|
|
shouldCaptureNextInput = true;
|
|
block();
|
|
return capturedInputs;
|
|
} finally {
|
|
capturedInputFrames.pop();
|
|
shouldCaptureNextInput = false;
|
|
}
|
|
}
|
|
|
|
protected _addInput(input: BaseObservable<any>) {
|
|
this._inputs.push(input);
|
|
if (this._attachedToInputs) {
|
|
this._attachToInput(input);
|
|
}
|
|
}
|
|
|
|
protected _removeInput(input: BaseObservable<any>) {
|
|
this._inputs.splice(this._inputs.indexOf(input), 1);
|
|
if (this._attachedToInputs) {
|
|
this._detachFromInput(input);
|
|
}
|
|
}
|
|
|
|
private _shouldAttachToInputs(): boolean {
|
|
// Only attach to inputs when at least one listener is subscribed to the observable or to one of its outputs.
|
|
// This is done to avoid unused observables being references by their inputs, preventing garbage-collection.
|
|
return this._listeners.length > 0 || this._outputs.length > 0;
|
|
}
|
|
|
|
private _attachToInputs() {
|
|
if (!this._attachedToInputs && this._shouldAttachToInputs()) {
|
|
this._attachedToInputs = true;
|
|
|
|
// Since the observable was not attached to its inputs, its value may be outdated.
|
|
// Refresh it so that listeners will be called with the correct prevValue the next time an input changes.
|
|
this._val = this._evaluate();
|
|
|
|
this.onBecomeObserved();
|
|
plugins.onBecomeObserved(this);
|
|
|
|
for (const input of this._inputs) {
|
|
this._attachToInput(input);
|
|
input._attachToInputs();
|
|
}
|
|
}
|
|
}
|
|
|
|
private _detachFromInputs() {
|
|
if (this._attachedToInputs && !this._shouldAttachToInputs()) {
|
|
this._attachedToInputs = false;
|
|
for (const input of this._inputs) {
|
|
this._detachFromInput(input);
|
|
input._detachFromInputs();
|
|
}
|
|
|
|
this.onBecomeUnobserved();
|
|
plugins.onBecomeUnobserved(this);
|
|
}
|
|
}
|
|
|
|
private _attachToInput(input: BaseObservable<any>) {
|
|
input._outputs.push(this);
|
|
plugins.onAttach(this, input);
|
|
}
|
|
|
|
private _detachFromInput(input: BaseObservable<any>) {
|
|
input._outputs.splice(input._outputs.indexOf(this), 1);
|
|
plugins.onDetach(this, input);
|
|
}
|
|
|
|
private _addToBatchRecursively() {
|
|
if (this._prevVal === UNSET) {
|
|
this._prevVal = this._val;
|
|
|
|
// Add the observable and its outputs in reverse topological order
|
|
for (const output of this._outputs) {
|
|
output._addToBatchRecursively();
|
|
}
|
|
batchedObservables.push(this);
|
|
}
|
|
}
|
|
|
|
protected static _batch(block: () => void) {
|
|
try {
|
|
batchDepth++;
|
|
if (batchDepth === 1 && batchedUpdater) {
|
|
batchedUpdater(block);
|
|
} else {
|
|
block();
|
|
}
|
|
} finally {
|
|
batchDepth--;
|
|
if (batchDepth === 0) {
|
|
const observablesToUpdate = batchedObservables;
|
|
batchedObservables = [];
|
|
|
|
// Iterate in reverse order as _addToBatchRecursively() adds them in reverse topological order
|
|
observablesToUpdate.reverse().forEach((observable) => {
|
|
const prevVal = observable._prevVal;
|
|
observable._prevVal = UNSET;
|
|
observable._val = observable._evaluate();
|
|
const val = observable._val;
|
|
|
|
if (val !== prevVal) {
|
|
for (const listener of observable._listeners.slice()) {
|
|
listener(val, prevVal);
|
|
}
|
|
plugins.onChange(observable, val, prevVal);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
protected static _use(plugin: Plugin) {
|
|
plugins.use(plugin);
|
|
}
|
|
}
|