@preact/signals-core
Version:
Manage state with style in every framework
877 lines (759 loc) • 21.6 kB
text/typescript
// An named symbol/brand for detecting Signal instances even when they weren't
// created using the same signals library version.
const BRAND_SYMBOL = Symbol.for("preact-signals");
// Flags for Computed and Effect.
const RUNNING = 1 << 0;
const NOTIFIED = 1 << 1;
const OUTDATED = 1 << 2;
const DISPOSED = 1 << 3;
const HAS_ERROR = 1 << 4;
const TRACKING = 1 << 5;
// A linked list node used to track dependencies (sources) and dependents (targets).
// Also used to remember the source's last version number that the target saw.
type Node = {
// A source whose value the target depends on.
_source: Signal;
_prevSource?: Node;
_nextSource?: Node;
// A target that depends on the source and should be notified when the source changes.
_target: Computed | Effect;
_prevTarget?: Node;
_nextTarget?: Node;
// The version number of the source that target has last seen. We use version numbers
// instead of storing the source value, because source values can take arbitrary amount
// of memory, and computeds could hang on to them forever because they're lazily evaluated.
// Use the special value -1 to mark potentially unused but recyclable nodes.
_version: number;
// Used to remember & roll back the source's previous `._node` value when entering &
// exiting a new evaluation context.
_rollbackNode?: Node;
};
function startBatch() {
batchDepth++;
}
function endBatch() {
if (batchDepth > 1) {
batchDepth--;
return;
}
let error: unknown;
let hasError = false;
while (batchedEffect !== undefined) {
let effect: Effect | undefined = batchedEffect;
batchedEffect = undefined;
batchIteration++;
while (effect !== undefined) {
const next: Effect | undefined = effect._nextBatchedEffect;
effect._nextBatchedEffect = undefined;
effect._flags &= ~NOTIFIED;
if (!(effect._flags & DISPOSED) && needsToRecompute(effect)) {
try {
effect._callback();
} catch (err) {
if (!hasError) {
error = err;
hasError = true;
}
}
}
effect = next;
}
}
batchIteration = 0;
batchDepth--;
if (hasError) {
throw error;
}
}
/**
* Combine multiple value updates into one "commit" at the end of the provided callback.
*
* Batches can be nested and changes are only flushed once the outermost batch callback
* completes.
*
* Accessing a signal that has been modified within a batch will reflect its updated
* value.
*
* @param fn The callback function.
* @returns The value returned by the callback.
*/
function batch<T>(fn: () => T): T {
if (batchDepth > 0) {
return fn();
}
/*@__INLINE__**/ startBatch();
try {
return fn();
} finally {
endBatch();
}
}
// Currently evaluated computed or effect.
let evalContext: Computed | Effect | undefined = undefined;
/**
* Run a callback function that can access signal values without
* subscribing to the signal updates.
*
* @param fn The callback function.
* @returns The value returned by the callback.
*/
function untracked<T>(fn: () => T): T {
const prevContext = evalContext;
evalContext = undefined;
try {
return fn();
} finally {
evalContext = prevContext;
}
}
// Effects collected into a batch.
let batchedEffect: Effect | undefined = undefined;
let batchDepth = 0;
let batchIteration = 0;
// A global version number for signals, used for fast-pathing repeated
// computed.peek()/computed.value calls when nothing has changed globally.
let globalVersion = 0;
function addDependency(signal: Signal): Node | undefined {
if (evalContext === undefined) {
return undefined;
}
let node = signal._node;
if (node === undefined || node._target !== evalContext) {
/**
* `signal` is a new dependency. Create a new dependency node, and set it
* as the tail of the current context's dependency list. e.g:
*
* { A <-> B }
* ↑ ↑
* tail node (new)
* ↓
* { A <-> B <-> C }
* ↑
* tail (evalContext._sources)
*/
node = {
_version: 0,
_source: signal,
_prevSource: evalContext._sources,
_nextSource: undefined,
_target: evalContext,
_prevTarget: undefined,
_nextTarget: undefined,
_rollbackNode: node,
};
if (evalContext._sources !== undefined) {
evalContext._sources._nextSource = node;
}
evalContext._sources = node;
signal._node = node;
// Subscribe to change notifications from this dependency if we're in an effect
// OR evaluating a computed signal that in turn has subscribers.
if (evalContext._flags & TRACKING) {
signal._subscribe(node);
}
return node;
} else if (node._version === -1) {
// `signal` is an existing dependency from a previous evaluation. Reuse it.
node._version = 0;
/**
* If `node` is not already the current tail of the dependency list (i.e.
* there is a next node in the list), then make the `node` the new tail. e.g:
*
* { A <-> B <-> C <-> D }
* ↑ ↑
* node ┌─── tail (evalContext._sources)
* └─────│─────┐
* ↓ ↓
* { A <-> C <-> D <-> B }
* ↑
* tail (evalContext._sources)
*/
if (node._nextSource !== undefined) {
node._nextSource._prevSource = node._prevSource;
if (node._prevSource !== undefined) {
node._prevSource._nextSource = node._nextSource;
}
node._prevSource = evalContext._sources;
node._nextSource = undefined;
evalContext._sources!._nextSource = node;
evalContext._sources = node;
}
// We can assume that the currently evaluated effect / computed signal is already
// subscribed to change notifications from `signal` if needed.
return node;
}
return undefined;
}
/**
* The base class for plain and computed signals.
*/
// @ts-ignore: "Cannot redeclare exported variable 'Signal'."
//
// A function with the same name is defined later, so we need to ignore TypeScript's
// warning about a redeclared variable.
//
// The class is declared here, but later implemented with ES5-style prototypes.
// This enables better control of the transpiled output size.
declare class Signal<T = any> {
/** @internal */
_value: unknown;
/**
* @internal
* Version numbers should always be >= 0, because the special value -1 is used
* by Nodes to signify potentially unused but recyclable nodes.
*/
_version: number;
/** @internal */
_node?: Node;
/** @internal */
_targets?: Node;
constructor(value?: T, options?: SignalOptions<T>);
/** @internal */
_refresh(): boolean;
/** @internal */
_subscribe(node: Node): void;
/** @internal */
_unsubscribe(node: Node): void;
/** @internal */
_watched?(this: Signal<T>): void;
/** @internal */
_unwatched?(this: Signal<T>): void;
subscribe(fn: (value: T) => void): () => void;
valueOf(): T;
toString(): string;
toJSON(): T;
peek(): T;
brand: typeof BRAND_SYMBOL;
get value(): T;
set value(value: T);
}
export interface SignalOptions<T = any> {
watched?: (this: Signal<T>) => void;
unwatched?: (this: Signal<T>) => void;
}
/** @internal */
// @ts-ignore: "Cannot redeclare exported variable 'Signal'."
//
// A class with the same name has already been declared, so we need to ignore
// TypeScript's warning about a redeclared variable.
//
// The previously declared class is implemented here with ES5-style prototypes.
// This enables better control of the transpiled output size.
function Signal(this: Signal, value?: unknown, options?: SignalOptions) {
this._value = value;
this._version = 0;
this._node = undefined;
this._targets = undefined;
this._watched = options?.watched;
this._unwatched = options?.unwatched;
}
Signal.prototype.brand = BRAND_SYMBOL;
Signal.prototype._refresh = function () {
return true;
};
Signal.prototype._subscribe = function (node) {
const targets = this._targets;
if (targets !== node && node._prevTarget === undefined) {
node._nextTarget = targets;
this._targets = node;
if (targets !== undefined) {
targets._prevTarget = node;
} else {
untracked(() => {
this._watched?.call(this);
});
}
}
};
Signal.prototype._unsubscribe = function (node) {
// Only run the unsubscribe step if the signal has any subscribers to begin with.
if (this._targets !== undefined) {
const prev = node._prevTarget;
const next = node._nextTarget;
if (prev !== undefined) {
prev._nextTarget = next;
node._prevTarget = undefined;
}
if (next !== undefined) {
next._prevTarget = prev;
node._nextTarget = undefined;
}
if (node === this._targets) {
this._targets = next;
if (next === undefined) {
untracked(() => {
this._unwatched?.call(this);
});
}
}
}
};
Signal.prototype.subscribe = function (fn) {
return effect(() => {
const value = this.value;
const prevContext = evalContext;
evalContext = undefined;
try {
fn(value);
} finally {
evalContext = prevContext;
}
});
};
Signal.prototype.valueOf = function () {
return this.value;
};
Signal.prototype.toString = function () {
return this.value + "";
};
Signal.prototype.toJSON = function () {
return this.value;
};
Signal.prototype.peek = function () {
const prevContext = evalContext;
evalContext = undefined;
try {
return this.value;
} finally {
evalContext = prevContext;
}
};
Object.defineProperty(Signal.prototype, "value", {
get(this: Signal) {
const node = addDependency(this);
if (node !== undefined) {
node._version = this._version;
}
return this._value;
},
set(this: Signal, value) {
if (value !== this._value) {
if (batchIteration > 100) {
throw new Error("Cycle detected");
}
this._value = value;
this._version++;
globalVersion++;
/**@__INLINE__*/ startBatch();
try {
for (
let node = this._targets;
node !== undefined;
node = node._nextTarget
) {
node._target._notify();
}
} finally {
endBatch();
}
}
},
});
/**
* Create a new plain signal.
*
* @param value The initial value for the signal.
* @returns A new signal.
*/
export function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
export function signal<T = undefined>(): Signal<T | undefined>;
export function signal<T>(value?: T, options?: SignalOptions<T>): Signal<T> {
return new Signal(value, options);
}
function needsToRecompute(target: Computed | Effect): boolean {
// Check the dependencies for changed values. The dependency list is already
// in order of use. Therefore if multiple dependencies have changed values, only
// the first used dependency is re-evaluated at this point.
for (
let node = target._sources;
node !== undefined;
node = node._nextSource
) {
if (
// If the dependency has definitely been updated since its version number
// was observed, then we need to recompute. This first check is not strictly
// necessary for correctness, but allows us to skip the refresh call if the
// dependency has already been updated.
node._source._version !== node._version ||
// Refresh the dependency. If there's something blocking the refresh (e.g. a
// dependency cycle), then we need to recompute.
!node._source._refresh() ||
// If the dependency got a new version after the refresh, then we need to recompute.
node._source._version !== node._version
) {
return true;
}
}
// If none of the dependencies have changed values since last recompute then
// there's no need to recompute.
return false;
}
function prepareSources(target: Computed | Effect) {
/**
* 1. Mark all current sources as re-usable nodes (version: -1)
* 2. Set a rollback node if the current node is being used in a different context
* 3. Point 'target._sources' to the tail of the doubly-linked list, e.g:
*
* { undefined <- A <-> B <-> C -> undefined }
* ↑ ↑
* │ └──────┐
* target._sources = A; (node is head) │
* ↓ │
* target._sources = C; (node is tail) ─┘
*/
for (
let node = target._sources;
node !== undefined;
node = node._nextSource
) {
const rollbackNode = node._source._node;
if (rollbackNode !== undefined) {
node._rollbackNode = rollbackNode;
}
node._source._node = node;
node._version = -1;
if (node._nextSource === undefined) {
target._sources = node;
break;
}
}
}
function cleanupSources(target: Computed | Effect) {
let node = target._sources;
let head: Node | undefined = undefined;
/**
* At this point 'target._sources' points to the tail of the doubly-linked list.
* It contains all existing sources + new sources in order of use.
* Iterate backwards until we find the head node while dropping old dependencies.
*/
while (node !== undefined) {
const prev = node._prevSource;
/**
* The node was not re-used, unsubscribe from its change notifications and remove itself
* from the doubly-linked list. e.g:
*
* { A <-> B <-> C }
* ↓
* { A <-> C }
*/
if (node._version === -1) {
node._source._unsubscribe(node);
if (prev !== undefined) {
prev._nextSource = node._nextSource;
}
if (node._nextSource !== undefined) {
node._nextSource._prevSource = prev;
}
} else {
/**
* The new head is the last node seen which wasn't removed/unsubscribed
* from the doubly-linked list. e.g:
*
* { A <-> B <-> C }
* ↑ ↑ ↑
* │ │ └ head = node
* │ └ head = node
* └ head = node
*/
head = node;
}
node._source._node = node._rollbackNode;
if (node._rollbackNode !== undefined) {
node._rollbackNode = undefined;
}
node = prev;
}
target._sources = head;
}
declare class Computed<T = any> extends Signal<T> {
_fn: () => T;
_sources?: Node;
_globalVersion: number;
_flags: number;
constructor(fn: () => T, options?: SignalOptions<T>);
_notify(): void;
get value(): T;
}
function Computed(this: Computed, fn: () => unknown, options?: SignalOptions) {
Signal.call(this, undefined);
this._fn = fn;
this._sources = undefined;
this._globalVersion = globalVersion - 1;
this._flags = OUTDATED;
this._watched = options?.watched;
this._unwatched = options?.unwatched;
}
Computed.prototype = new Signal() as Computed;
Computed.prototype._refresh = function () {
this._flags &= ~NOTIFIED;
if (this._flags & RUNNING) {
return false;
}
// If this computed signal has subscribed to updates from its dependencies
// (TRACKING flag set) and none of them have notified about changes (OUTDATED
// flag not set), then the computed value can't have changed.
if ((this._flags & (OUTDATED | TRACKING)) === TRACKING) {
return true;
}
this._flags &= ~OUTDATED;
if (this._globalVersion === globalVersion) {
return true;
}
this._globalVersion = globalVersion;
// Mark this computed signal running before checking the dependencies for value
// changes, so that the RUNNING flag can be used to notice cyclical dependencies.
this._flags |= RUNNING;
if (this._version > 0 && !needsToRecompute(this)) {
this._flags &= ~RUNNING;
return true;
}
const prevContext = evalContext;
try {
prepareSources(this);
evalContext = this;
const value = this._fn();
if (
this._flags & HAS_ERROR ||
this._value !== value ||
this._version === 0
) {
this._value = value;
this._flags &= ~HAS_ERROR;
this._version++;
}
} catch (err) {
this._value = err;
this._flags |= HAS_ERROR;
this._version++;
}
evalContext = prevContext;
cleanupSources(this);
this._flags &= ~RUNNING;
return true;
};
Computed.prototype._subscribe = function (node) {
if (this._targets === undefined) {
this._flags |= OUTDATED | TRACKING;
// A computed signal subscribes lazily to its dependencies when it
// gets its first subscriber.
for (
let node = this._sources;
node !== undefined;
node = node._nextSource
) {
node._source._subscribe(node);
}
}
Signal.prototype._subscribe.call(this, node);
};
Computed.prototype._unsubscribe = function (node) {
// Only run the unsubscribe step if the computed signal has any subscribers.
if (this._targets !== undefined) {
Signal.prototype._unsubscribe.call(this, node);
// Computed signal unsubscribes from its dependencies when it loses its last subscriber.
// This makes it possible for unreferences subgraphs of computed signals to get garbage collected.
if (this._targets === undefined) {
this._flags &= ~TRACKING;
for (
let node = this._sources;
node !== undefined;
node = node._nextSource
) {
node._source._unsubscribe(node);
}
}
}
};
Computed.prototype._notify = function () {
if (!(this._flags & NOTIFIED)) {
this._flags |= OUTDATED | NOTIFIED;
for (
let node = this._targets;
node !== undefined;
node = node._nextTarget
) {
node._target._notify();
}
}
};
Object.defineProperty(Computed.prototype, "value", {
get(this: Computed) {
if (this._flags & RUNNING) {
throw new Error("Cycle detected");
}
const node = addDependency(this);
this._refresh();
if (node !== undefined) {
node._version = this._version;
}
if (this._flags & HAS_ERROR) {
throw this._value;
}
return this._value;
},
});
/**
* An interface for read-only signals.
*/
interface ReadonlySignal<T = any> {
readonly value: T;
peek(): T;
subscribe(fn: (value: T) => void): () => void;
valueOf(): T;
toString(): string;
toJSON(): T;
brand: typeof BRAND_SYMBOL;
}
/**
* Create a new signal that is computed based on the values of other signals.
*
* The returned computed signal is read-only, and its value is automatically
* updated when any signals accessed from within the callback function change.
*
* @param fn The effect callback.
* @returns A new read-only signal.
*/
function computed<T>(
fn: () => T,
options?: SignalOptions<T>
): ReadonlySignal<T> {
return new Computed(fn, options);
}
function cleanupEffect(effect: Effect) {
const cleanup = effect._cleanup;
effect._cleanup = undefined;
if (typeof cleanup === "function") {
/*@__INLINE__**/ startBatch();
// Run cleanup functions always outside of any context.
const prevContext = evalContext;
evalContext = undefined;
try {
cleanup();
} catch (err) {
effect._flags &= ~RUNNING;
effect._flags |= DISPOSED;
disposeEffect(effect);
throw err;
} finally {
evalContext = prevContext;
endBatch();
}
}
}
function disposeEffect(effect: Effect) {
for (
let node = effect._sources;
node !== undefined;
node = node._nextSource
) {
node._source._unsubscribe(node);
}
effect._fn = undefined;
effect._sources = undefined;
cleanupEffect(effect);
}
function endEffect(this: Effect, prevContext?: Computed | Effect) {
if (evalContext !== this) {
throw new Error("Out-of-order effect");
}
cleanupSources(this);
evalContext = prevContext;
this._flags &= ~RUNNING;
if (this._flags & DISPOSED) {
disposeEffect(this);
}
endBatch();
}
type EffectFn =
| ((this: { dispose: () => void }) => void | (() => void))
| (() => void | (() => void));
declare class Effect {
_fn?: EffectFn;
_cleanup?: () => void;
_sources?: Node;
_nextBatchedEffect?: Effect;
_flags: number;
constructor(fn: EffectFn);
_callback(): void;
_start(): () => void;
_notify(): void;
_dispose(): void;
dispose(): void;
}
function Effect(this: Effect, fn: EffectFn) {
this._fn = fn;
this._cleanup = undefined;
this._sources = undefined;
this._nextBatchedEffect = undefined;
this._flags = TRACKING;
}
Effect.prototype._callback = function () {
const finish = this._start();
try {
if (this._flags & DISPOSED) return;
if (this._fn === undefined) return;
const cleanup = this._fn();
if (typeof cleanup === "function") {
this._cleanup = cleanup;
}
} finally {
finish();
}
};
Effect.prototype._start = function () {
if (this._flags & RUNNING) {
throw new Error("Cycle detected");
}
this._flags |= RUNNING;
this._flags &= ~DISPOSED;
cleanupEffect(this);
prepareSources(this);
/*@__INLINE__**/ startBatch();
const prevContext = evalContext;
evalContext = this;
return endEffect.bind(this, prevContext);
};
Effect.prototype._notify = function () {
if (!(this._flags & NOTIFIED)) {
this._flags |= NOTIFIED;
this._nextBatchedEffect = batchedEffect;
batchedEffect = this;
}
};
Effect.prototype._dispose = function () {
this._flags |= DISPOSED;
if (!(this._flags & RUNNING)) {
disposeEffect(this);
}
};
Effect.prototype.dispose = function () {
this._dispose();
};
/**
* Create an effect to run arbitrary code in response to signal changes.
*
* An effect tracks which signals are accessed within the given callback
* function `fn`, and re-runs the callback when those signals change.
*
* The callback may return a cleanup function. The cleanup function gets
* run once, either when the callback is next called or when the effect
* gets disposed, whichever happens first.
*
* @param fn The effect callback.
* @returns A function for disposing the effect.
*/
function effect(fn: EffectFn): { (): void; [Symbol.dispose](): void } {
const effect = new Effect(fn);
try {
effect._callback();
} catch (err) {
effect._dispose();
throw err;
}
// Return a bound function instead of a wrapper like `() => effect._dispose()`,
// because bound functions seem to be just as fast and take up a lot less memory.
const dispose = effect._dispose.bind(effect);
(dispose as any)[Symbol.dispose] = dispose;
return dispose as any;
}
export { computed, effect, batch, untracked, Signal, ReadonlySignal };