@oazmi/tsignal
Version:
a topological order respecting signals library inspired by SolidJS
162 lines (161 loc) • 10.6 kB
TypeScript
/** base type definitions <br>
* @module
*/
/** type definition for a value equality check function. */
export type EqualityFn<T> = (prev_value: T | undefined, new_value: T) => boolean;
/** type definition for an equality check specification. <br>
* when `undefined`, javascript's regular `===` equality will be used. <br>
* when `false`, equality will always be evaluated to false, meaning that setting any value will always fire a signal, even if it's equal.
*/
export type EqualityCheck<T> = undefined | false | EqualityFn<T>;
/** the {@link Signal.id | `id`} of a signal is simply a number. */
export type ID = number;
/** another way of saying the dependency (or source) signal id. */
export type FROM_ID = ID;
/** another way of saying the observer (or destination) signal id. */
export type TO_ID = ID;
/** the {@link Signal.rid | runtime id (`rid`)} of a signal becomes zero after its first run (because it has captured all of its dependencies). */
export type UNTRACKED_ID = 0;
/** the hash value generated by the dfs-signal-dag-traversal hash function. */
export type HASHED_IDS = number;
/** type definition for an incremental updater function. */
export type Updater<T> = (prev_value?: T) => T;
/** type definition for a signal accessor (value getter) function. */
export type Accessor<T> = ((observer_id?: TO_ID | UNTRACKED_ID) => T);
/** type definition for a signal value setter function. */
export type Setter<T> = ((new_value: T | Updater<T>) => boolean);
/** type definition for an async signal value setter function. <br>
* TODO: should an `AsyncSetter<T>` return a `Promise<T>` ? or should it return a `Promise<boolean>`, which should tell whether or not the value has changed (i.e. `!signal.equal(old_value, new_value)`)
*/
export type AsyncSetter<T> = (new_value: (T | Promise<T | Updater<T>>) | Updater<T | Promise<T | Updater<T>>>, rejectable?: boolean) => Promise<T>;
/** type definition for when a signal's _update_ function `run` is called by the signal update propagator `propagateSignalUpdate` inside of {@link context!Context}. <br>
* the return value should indicate whether this signal has:
* - updated ({@link SignalUpdateStatus.UPDATED} === 1), and therefore propagate to its observers to also run
* - unchanged ({@link SignalUpdateStatus.UNCHANGED} === 0), and therefore not propagate to its observers
* - aborted ({@link SignalUpdateStatus.ABORTED} === -1), and therefore force each of its observers to also become non propagating and inherit the {@link SignalUpdateStatus.ABORTED} status
*/
export type Runner = () => SignalUpdateStatus;
/** the abstraction that defines what a signal is. */
export interface Signal<T> {
/** id of this signal in the {@link context!Context} in which it exists. */
id: number;
/** runtime-id of this signal. <br>
* it equals to the {@link id} on the first, so that the dependencies of this signal can be notified of being observed.
* but once all dependencies have been notified after the first run, this runtime-id should become `0` ({@link UNTRACKED_ID}),
* so that the dependiencies do not have to re-register this signal as an observer.
*/
rid: ID | UNTRACKED_ID;
/** give a name to this signal for debugging purposes */
name?: string;
/** get the value of this signal, and handle any observing signal's id ({@link observer_id}). <br>
* typically, when `observer_id` is non-zero, this signal should handle it by registering it as an
* observer through the use of the context's {@link context!Context.addEdge} method.
*
* @example
* ```ts
* const MySignalClass_Factory = (ctx: Context) => {
* const addEdge = ctx.addEdge
* return class MySignal<T> implements Signal<T> {
* declare value: T
* // ...
* get(observer_id?: TO_ID | UNTRACKED_ID): T {
* // register this.id to observer (if non-zero) in the dependency graph as a directed edge
* if (observer_id) { addEdge(this.id, observer_id) }
* return this.value
* }
* // ...
* }
* }
* ```
*/
get(observer_id?: TO_ID | UNTRACKED_ID): T;
/** set the value of this signal. <br>
* the meaning of setting a signal's value greatly varies from signal to signal, which is why it is so abstracted. <br>
* however, the returning value must always be a `boolean` describing whether or not this signal's value has changed compared to its previous value. <br>
* what makes use of the returned value again greatly varies from signal to signal.
* but it is typically used by the {@link run} method to decide whether or not this signal should propagate. <br>
* another purpose of the set method is typically to _initiate_ the ignition of an update cycle in a context, bu using {@link context!Context.runId}.
* this is how {@link signal!StateSignal}s and {@link signal!EffectSignal}s begin an update cycle when their values have changed from the prior value.
*
* @example
* ```ts
* const MySignalClass_Factory = (ctx: Context) => {
* const runId = ctx.runId
* return class MySignal<T> implements Signal<T> {
* declare value: T
* // ...
* set(new_value: T): boolean {
* const value_has_changed = new_value !== this.value
* if (value_has_changed) {
* runId(this.id)
* return true
* }
* return false
* }
* // ...
* }
* }
* ```
*/
set?(...args: any[]): boolean;
/** specify actions that need to be taken __before__ an update cycle has even begun propagating. <br>
* TODO: CURRENTLY NOT IMPLEMENTED.
* ISSUE: what should the order in which prepruns run be? we do know the FULL set of signal ids that will be visited.
* but we do not know the subset of ids that WILL BE affected and ran, not until run time of the signal propagation.
* should ids that _might_ be affected also have their preruns ran? and in what order? because we cannot know the order until propagation runtime.
*/
prerun?(): void;
/** run the actions taken by a signal when it is informed that its dependency signals have been modified/changed. <br>
* the return value should be of the numertic enum kind {@link SignalUpdateStatus}, which specifies that this signal has been either been:
* - ` 1`: updated, and therefore this signal's observers should be notified (i.e. _ran_ via their `run` method)
* - ` 0`: unchanged, and therefore this signal's observers should be notified if this is their only active dependency
* - `-1`: aborted, and therefore this signal's observer should also abort their `run` method's execution if they were queued
*
* this method may also accept an optional `forced` parameter, which tells the signal that it is
* being _forced_ to run, even though none of its dependency signals have been executed or changed.
* this information is useful when coding for signals that _can_ be fired independently, such as {@link signal!StateSignal} or {@link signal!EffectSignal}.
*
* @param forced was this signal _forced_ to run independently?
* @returns the update status of this signal, specifying whether or not it has changed, or if it has been aborted
*/
run(forced?: boolean): SignalUpdateStatus;
/** specify actions that need to be taken __after__ an update cycle has fully propagated till the end. <br>
* the order in which `postrun`s will be executed will be in the reverse order in which they were first encountered (i.e: last in, last out).
* meaning that if all three signals `A`, `B`, and `C`, had `postrun` methods on them, and the order of execution was:
* `A -> B -> C`, then all `postrun`s will run in the order: `[C.postrun, B.postrun, A.postrun]` (similar to a stack popping).
*/
postrun?(): void;
/** a utility method defined in {@link signal!SimpleSignal}, which allows one to bind a certain method (by name) to _this_ instance of a signal,
* and therefore make that method freeable/seperable from _this_ signal. <br>
* this method is used by all signal classes's static {@link SignalClass.create} method, which is supposed to construct a signal in
* a fashion similar to SolidJS, and return an array containing important control functions of the created signal.
* most, if not all, of these control function are generally plain old signal methods that have been bounded to the created signal instance.
*/
bindMethod<M extends keyof this>(method_name: M): this[M];
}
/** the abstraction that defines the static methods which must exist in a signal generating class */
export interface SignalClass {
new (...args: any[]): Signal<any>;
create(...args: any[]): [id: ID, ...any[]];
}
/** the numbers used for relaying the status of a signal after it has been _ran_ via its {@link Signal.run | `run method`}. <br>
* these numbers convey the following instructions to the context's topological update cycle {@link context!Context.propagateSignalUpdate}:
* - ` 1`: this signal's value has been updated, and therefore its observers should be updated too.
* - ` 0`: this signal's value has not changed, and therefore its observers should be _not_ be updated.
* do note that an observer signal will still run if some _other_ of its dependency signal did update this cycle (i.e. had a status value of `1`)
* - `-1`: this signal has been aborted, and therefore its observers must abort execution as well.
* the observers will abort _even_ if they had a dependency that _did_ update (had a status value of `1`)
*
* to sum up, given a signal `D`, with dependencies: `A`, `B`, and `C` (all of which are mutually independent of each other).
* then the status of `D` will be as follows in the order of highest conditional priority to lowest:
* | status of D | status of D as enum | condition |
* |-----------------------|:-------------------------------------:|:-----------------------------------------------------------------------------:|
* | `status(D) = -1` | `ABORTED` | `∃X ∈ [A, B, C] such that status(X) === -1` |
* | `status(D) = D.run()` | `CHANGED` or `UNCHANGED` or `ABORTED` | `∃X ∈ [A, B, C] such that status(X) === 1` |
* | `status(D) = 0` | `UNCHANGED` | `∀X ∈ [A, B, C], status(X) === 0` |
*/
export declare const enum SignalUpdateStatus {
ABORTED = -1,
UNCHANGED = 0,
UPDATED = 1
}