sprae
Version:
DOM microhydration
106 lines (94 loc) • 3.06 kB
JavaScript
/**
* @fileoverview Minimal signals implementation (preact-signals compatible)
* @module sprae/signal
*/
/** @type {import('./core.js').EffectFn | null} */
let current
let depth = 0
/** @type {Set<import('./core.js').EffectFn> | null} */
let batched;
// class keeps instances light: prototype accessors share one shape, subscribers Set allocates lazily
class Signal {
constructor(v) { this.v = v; this.o = null }
get value() {
if (current) current.deps.add((this.o ??= new Set).add(current))
return this.v
}
set value(val) {
if (val === this.v) return
this.v = val
if (this.o) for (let sub of this.o) batched ? batched.add(sub) : sub() // notify effects
}
peek() { return this.v }
valueOf() { return this.value }
toString() { return this.value }
toJSON() { return this.value }
}
/**
* Creates a reactive signal.
* @template T
* @param {T} v - Initial value
* @returns {import('./core.js').Signal<T>}
*/
export const signal = (v) => new Signal(v)
/**
* Creates a reactive effect that re-runs when dependencies change.
* @param {() => void | (() => void)} fn - Effect function, may return cleanup
* @returns {() => void} Dispose function
*/
export const effect = (fn, _teardown, _fx, _deps) => (
_fx = (prev) => {
if (!fn) return // disposed during batch flush
let tmp = _teardown;
_teardown = null;
tmp?.call?.();
prev = current, current = _fx
if (depth++ > 50) {
depth--; current = prev;
// dispose: unsubscribe from all deps so this effect never fires again
_teardown = fn = _fx.fn = null; for (let dep of _deps) dep.delete(_fx); _deps.clear()
console.error('∴ Reactive loop detected'); return
}
try { _teardown = fn() } finally { current = prev; depth-- }
},
_fx.fn = fn,
_deps = _fx.deps = new Set(),
_fx(),
(dep) => { _teardown?.call?.(); _teardown = fn = _fx.fn = null; for (dep of _deps) dep.delete(_fx); _deps.clear() }
)
// a computed is a signal with a lazy producer effect keeping it current
class Computed extends Signal {
constructor(fn) { super(); this.fn = fn; this.e = null }
get value() {
this.e ||= effect(() => super.value = this.fn())
return super.value
}
}
/**
* Creates a computed signal derived from other signals.
* @template T
* @param {() => T} fn - Computation function
* @returns {import('./core.js').Signal<T>}
*/
export const computed = (fn) => new Computed(fn)
/**
* Batches multiple signal updates into a single notification.
* @template T
* @param {() => T} fn - Function containing updates
* @returns {T}
*/
export const batch = (fn, _first = !batched, _list) => {
batched ??= new Set;
try { fn(); }
finally { if (_first) { [batched, _list] = [null, batched]; for (const fx of _list) fx(); } }
}
/**
* Runs a function without tracking dependencies.
* @template T
* @param {() => T} fn - Function to run untracked
* @returns {T}
*/
export const untracked = (fn, _prev) => {
_prev = current, current = null
try { return fn() } finally { current = _prev }
}