@oazmi/tsignal
Version:
a topological order respecting signals library inspired by SolidJS
217 lines (216 loc) • 8.9 kB
JavaScript
/** a equal-calorie clone of the popular reactivity library [SolidJS](https://github.com/solidjs/solid). <br>
* @module
*/
import { DEBUG, bindMethodToSelfByName, isFunction } from "./deps.js";
import { log_get_request, parseEquality } from "./funcdefs.js";
import { SignalUpdateStatus } from "./typedefs.js";
/** the base signal class inherited by most other signal classes. <br>
* its only function is to:
* - when {@link Signal.get | read}, it return its `this.value`, and register any new observers (those with a nonzero runtime-id {@link Signal.rid | `Signal.rid`})
* - if {@link Signal.set | set} to a new value, compare it to its previous value through its `this.equals` function,
* and return a boolean specifying whether or not the old and new values are the same.
* - when {@link Signal.run | ran}, it will always return `0` (unchanged), unless it is forced, in which case it will return a `1`.
*/
export const SimpleSignal_Factory = (ctx) => {
const { newId, getId, setId, addEdge } = ctx;
/** {@inheritDoc SimpleSignal_Factory} */
return class SimpleSignal {
constructor(value, { name, equals, } = {}) {
const id = newId();
// register the new signal
setId(id, this);
this.id = id;
this.rid = id;
this.name = name;
this.value = value;
this.equals = parseEquality(equals);
}
get(observer_id) {
if (observer_id) {
// register this.id to observer
addEdge(this.id, observer_id);
}
if (DEBUG.LOG) {
log_get_request(getId, this.id, observer_id);
}
return this.value;
}
set(new_value) {
const old_value = this.value;
return !this.equals(old_value, (this.value = isFunction(new_value) ?
new_value(old_value) :
new_value));
}
run(forced) {
return forced ?
SignalUpdateStatus.UPDATED :
SignalUpdateStatus.UNCHANGED;
}
bindMethod(method_name) {
return bindMethodToSelfByName(this, method_name);
}
static create(...args) {
const new_signal = new this(...args);
return [new_signal.id, new_signal];
}
};
};
/** creates state signals, which when {@link Signal.set | set} to a changed value, it will fire an update to all of its dependent/observer signals. */
export const StateSignal_Factory = (ctx) => {
const runId = ctx.runId;
/** {@inheritDoc StateSignal_Factory} */
return class StateSignal extends ctx.getClass(SimpleSignal_Factory) {
constructor(value, config) {
super(value, config);
}
set(new_value) {
// if value has changed, then fire this id to begin/queue a firing cycle
const value_has_changed = super.set(new_value);
if (value_has_changed) {
runId(this.id);
return true;
}
return false;
}
static create(value, config) {
const new_signal = new this(value, config);
return [
new_signal.id,
new_signal.bindMethod("get"),
new_signal.bindMethod("set"),
];
}
};
};
/** creates a computational/derived signal that only fires again if at least one of its dependencies has fired,
* and after the {@link SimpleSignalInstance.fn | recomputation} (`this.fn`), the new computed value is different from the old one (according to `this.equals`).
*/
export const MemoSignal_Factory = (ctx) => {
/** {@inheritDoc MemoSignal_Factory} */
return class MemoSignal extends ctx.getClass(SimpleSignal_Factory) {
constructor(fn, config) {
super(config?.value, config);
this.fn = fn;
if (config?.defer === false) {
this.get();
}
}
get(observer_id) {
if (this.rid) {
this.run();
this.rid = 0;
}
return super.get(observer_id);
}
// TODO: consider whether or not MemoSignals should be able to be forced to fire independently
run(forced) {
return super.set(this.fn(this.rid)) ?
SignalUpdateStatus.UPDATED :
SignalUpdateStatus.UNCHANGED;
}
static create(fn, config) {
const new_signal = new this(fn, config);
return [
new_signal.id,
new_signal.bindMethod("get")
];
}
};
};
/** similar to {@link MemoSignal_Factory | `MemoSignal`}, creates a computed/derived signal, but it only recomputes if:
* - it is dirty (`this.dirty = 1`)
* - AND some signal/observer/caller calls this signal to {@link Signal.get | get} its value.
*
* this signal becomes dirty when at least one of its dependencies has fired an update. <br>
* after which, it will remain dirty unless some caller requests its value, after which it will become not-dirty again.
*
* this signal also always fires an update when at least one of its dependencies has fired an update.
* and it abandons checking for equality all together, since it only recomputes after a get request,
* by which it is too late to signal no update in the value (because its observer is already running).
*
* this signal becomes pointless (in terms of efficiency) once a {@link MemoSignal_Factory | `MemoSignal`} depends on it.
* but it is increadibly useful (i.e. lazy) when other {@link LazySignal_Factory | `LazySignal`s} depend on one another.
*/
export const LazySignal_Factory = (ctx) => {
/** {@inheritDoc LazySignal_Factory} */
return class LazySignal extends ctx.getClass(SimpleSignal_Factory) {
constructor(fn, config) {
super(config?.value, config);
this.fn = fn;
this.dirty = 1;
if (config?.defer === false) {
this.get();
}
}
run(forced) {
return (this.dirty = 1);
}
get(observer_id) {
if (this.rid || this.dirty) {
super.set(this.fn(this.rid));
this.dirty = 0;
this.rid = 0;
}
return super.get(observer_id);
}
static create(fn, config) {
const new_signal = new this(fn, config);
return [
new_signal.id,
new_signal.bindMethod("get")
];
}
};
};
/** extremely similar to {@link MemoSignal_Factory | `MemoSignal`}, but without a value to output, and also has the ability to fire on its own.
* TODO-DOC: explain more
*/
export const EffectSignal_Factory = (ctx) => {
const runId = ctx.runId;
/** {@inheritDoc EffectSignal_Factory} */
return class EffectSignal extends ctx.getClass(SimpleSignal_Factory) {
constructor(fn, config) {
super(undefined, config);
this.fn = fn;
if (config?.defer === false) {
this.set();
}
}
/** a non-untracked observer (which is what all new observers are) depending on an effect signal will result in the triggering of effect function.
* this is an intentional design choice so that effects can be scaffolded on top of other effects.
* TODO: reconsider, because you can also check for `this.rid !== 0` to determine that `this.fn` effect function has never run before, thus it must run at least once if the observer is not untracked_id
* is it really necessary for us to rerun `this.fn` effect function for every new observer? it seems to create chaos rather than reducing it.
* UPDATE: decided NOT to re-run on every new observer
* TODO: cleanup this messy doc and redeclare how createEffect works
*/
get(observer_id) {
if (observer_id) {
if (this.rid) {
this.run();
}
super.get(observer_id);
}
}
set() {
const effect_will_fire_immediately = runId(this.id);
return effect_will_fire_immediately;
}
run(forced) {
const signal_should_propagate = this.fn(this.rid) !== false;
if (this.rid) {
this.rid = 0;
}
return signal_should_propagate ?
SignalUpdateStatus.UPDATED :
SignalUpdateStatus.UNCHANGED;
}
static create(fn, config) {
const new_signal = new this(fn, config);
return [
new_signal.id,
new_signal.bindMethod("get"),
new_signal.bindMethod("set"),
];
}
};
};