You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/lib/Signal.ts

195 lines
5.9 KiB

import { DisposableWithEvents } from 'app/common/DisposableWithEvents';
import { Disposable, IDisposable, IDisposableOwner, Observable } from 'grainjs';
/**
* A simple abstraction for events composition. It is an object that can emit a single value of type T,
* and holds the last value emitted. It can be used to compose events from other events.
*
* Simple observables can't be used for this purpose because they are not reentrant. We can't update
* an observable from within a listener, because it won't trigger a new event.
*
* This class is basically a wrapper around Observable, that emits events when the value changes after it is
* set.
*
* Example:
* const signal = Signal.create(null, 0);
* signal.listen(value => console.log(value));
* const onlyEven = signal.filter(value => value % 2 === 0);
* onlyEven.listen(value => console.log('even', value));
*
* const flag1 = Signal.create(null, false);
* const flag2 = Signal.create(null, false);
* const flagAnd = Signal.compute(null, on => on(flag1) && on(flag2));
* // This will still emit multiple times with the same value repeated.
* flagAnd.listen(value => console.log('Both are true', value));
*
* // This will emit only when both are true, and will ignore further changes while both are true.
* const toggle = flagAnd.distinct();
*
* // Current value can be accessed via signal.state.get()
* const emitter = Signal.from(null, 0);
* // Emit values only when the toggle is true.
* const emitterWhileAnd = emitter.filter(() => toggle.state.get());
* // Equivalent to:
* const emitterWhileAnd = Signal.compute(null, on => on(toggle) ? on(emitter) : null).distinct();
*/
export class Signal<T = any> implements IDisposable, IDisposableOwner {
/**
* Creates a new event with a default value. A convenience method for creating an event that supports
* generic attribute.
*/
public static create<T>(owner: IDisposableOwner | null, value: T) {
return new Signal(owner, value);
}
/**
* Creates an event from a set of events. Holds last value emitted by any of the events.
*/
public static fromEvents<T = any>(
owner: Disposable | null,
emitter: any,
first: string,
...rest: string[]
) {
const signal = Signal.create(owner, null);
for(const event of [first, ...rest]) {
signal._emitter.listenTo(emitter, event, (value: any) => signal.emit(value));
}
return signal as Signal<T | null>;
}
/**
* Helper methods that creates a signal that emits the result of a function that takes a function
*/
public static compute<T>(owner: Disposable | null, compute: ComputeFunction<T>) {
const signal = Signal.create(owner, null as any);
const on: any = (s: Signal) => {
if (!signal._listeners.has(s)) {
signal._listeners.add(s);
signal._emitter.listenTo(s._emitter, 'signal', () => signal.emit(compute(on)));
}
return s.state.get();
};
signal.state.set(compute(on));
return signal as Signal<T>;
}
/**
* Last value emitted if any.
*/
public state: Observable<T>;
/**
* List of signals that we are listening to. Stored in a WeakSet to avoid memory leaks.
*/
private _listeners: WeakSet<Signal> = new WeakSet();
/**
* Flag that can be changed by stateless() function. It won't hold last value (but can't be used in compute function).
*/
private _emitter: DisposableWithEvents;
private _beforeHandler: CustomEmitter<T>;
constructor(owner: IDisposableOwner|null, initialValue: T) {
this._emitter = DisposableWithEvents.create(owner);
this.state = Observable.create(this, initialValue);
}
public dispose() {
this._emitter.dispose();
}
public autoDispose(disposable: IDisposable) {
this._emitter.autoDispose(disposable);
}
/**
* Push all events from this signal to another signal.
*/
public pipe(signal: Signal<T>) {
this.autoDispose(this.listen(value => signal.emit(value)));
return this;
}
/**
* Modify all values emitted by this signal.
*/
public map<Z>(selector: (value: T) => Z): Signal<Z> {
const signal = Signal.create(this, selector(this.state.get()));
this.listen(value => {
signal.emit(selector(value));
});
return signal;
}
/**
* Creates a new signal with the same state, but it will only
* emit those values that pass the test implemented by the provided function.
*/
public filter(selector: (value: T) => boolean): Signal<T> {
const signal = Signal.create(this, this.state.get());
this.listen(value => {
if (selector(value)) {
signal.emit(value);
}
});
return signal;
}
/**
* Emit only the value that is different from the previous one.
*/
public distinct(): Signal<T> {
let last = this.state.get();
const signal = this.filter((value: any) => {
if (value !== last) {
last = value;
return true;
}
return false;
});
signal.state.set(last);
return signal;
}
/**
* Emits true or false only when the value is changed from truthy to falsy or vice versa.
*/
public flag() {
return this.map(Boolean).distinct();
}
/**
* Listen to changes of the signal.
*/
public listen(handler: (value: T) => any) {
const stateHandler = () => {
handler(this.state.get());
};
this._emitter.on('signal', stateHandler);
return {
dispose: () => this._emitter.off('signal', stateHandler),
};
}
public emit(value: T) {
if (this._beforeHandler) {
this._beforeHandler(value, (emitted: T) => {
this.state.set(emitted);
this._emitter.trigger('signal', emitted);
});
} else {
this.state.set(value);
this._emitter.trigger('signal', value);
}
}
public before(handler: CustomEmitter<T>) {
this._beforeHandler = handler;
}
}
type ComputeFunction<T> = (on: <TS>(s: Signal<TS>) => TS) => T;
type CustomEmitter<T> = (value: T, emit: (value: T) => void) => any;