@zeix/cause-effect
Version:
Cause & Effect - reactive state management with signals.
173 lines (152 loc) • 3.9 kB
text/typescript
/* === Types === */
type Cleanup = () => void
type Watcher = {
(): void
off(cleanup: Cleanup): void
cleanup(): void
}
type Updater = <T>() => T | boolean | void
/* === Internal === */
// Currently active watcher
let active: Watcher | undefined
// Pending queue for batched change notifications
const pending = new Set<Watcher>()
let batchDepth = 0
// Map of deduplication symbols to update functions (using Symbol keys prevents unintended overwrites)
const updateMap = new Map<symbol, Updater>()
let requestId: number | undefined
const updateDOM = () => {
requestId = undefined
const updates = Array.from(updateMap.values())
updateMap.clear()
for (const update of updates) {
update()
}
}
const requestTick = () => {
if (requestId) cancelAnimationFrame(requestId)
requestId = requestAnimationFrame(updateDOM)
}
// Initial render when the call stack is empty
queueMicrotask(updateDOM)
/* === Functions === */
/**
* Create a watcher that can be used to observe changes to a signal
*
* @since 0.14.1
* @param {() => void} notice - function to be called when the state changes
* @returns {Watcher} - watcher object with off and cleanup methods
*/
const watch = (notice: () => void): Watcher => {
const cleanups = new Set<Cleanup>()
const w = notice as Partial<Watcher>
w.off = (on: Cleanup) => {
cleanups.add(on)
}
w.cleanup = () => {
for (const cleanup of cleanups) {
cleanup()
}
cleanups.clear()
}
return w as Watcher
}
/**
* Add active watcher to the Set of watchers
*
* @param {Set<Watcher>} watchers - watchers of the signal
*/
const subscribe = (watchers: Set<Watcher>) => {
if (active && !watchers.has(active)) {
const watcher = active
watchers.add(watcher)
active.off(() => {
watchers.delete(watcher)
})
}
}
/**
* Add watchers to the pending set of change notifications
*
* @param {Set<Watcher>} watchers - watchers of the signal
*/
const notify = (watchers: Set<Watcher>) => {
for (const watcher of watchers) {
if (batchDepth) pending.add(watcher)
else watcher()
}
}
/**
* Flush all pending changes to notify watchers
*/
const flush = () => {
while (pending.size) {
const watchers = Array.from(pending)
pending.clear()
for (const watcher of watchers) {
watcher()
}
}
}
/**
* Batch multiple changes in a single signal graph and DOM update cycle
*
* @param {() => void} fn - function with multiple signal writes to be batched
*/
const batch = (fn: () => void) => {
batchDepth++
try {
fn()
} finally {
flush()
batchDepth--
}
}
/**
* Run a function in a reactive context
*
* @param {() => void} run - function to run the computation or effect
* @param {Watcher} watcher - function to be called when the state changes or undefined for temporary unwatching while inserting auto-hydrating DOM nodes that might read signals (e.g., web components)
*/
const observe = (run: () => void, watcher?: Watcher): void => {
const prev = active
active = watcher
try {
run()
} finally {
active = prev
}
}
/**
* Enqueue a function to be executed on the next animation frame
*
* If the same Symbol is provided for multiple calls before the next animation frame,
* only the latest call will be executed (deduplication).
*
* @param {Updater} fn - function to be executed on the next animation frame; can return updated value <T>, success <boolean> or void
* @param {symbol} dedupe - Symbol for deduplication; if not provided, a unique Symbol is created ensuring the update is always executed
*/
const enqueue = <T>(fn: Updater, dedupe?: symbol) =>
new Promise<T | boolean | void>((resolve, reject) => {
updateMap.set(dedupe || Symbol(), () => {
try {
resolve(fn())
} catch (error) {
reject(error)
}
})
requestTick()
})
/* === Exports === */
export {
type Cleanup,
type Watcher,
type Updater,
subscribe,
notify,
flush,
batch,
watch,
observe,
enqueue,
}