UNPKG

simplyflow

Version:

Flow based programming in javascript, with signals and effects

548 lines (512 loc) 18.5 kB
const iterate = Symbol('iterate') if (!Symbol.xRay) { Symbol.xRay = Symbol('xRay') } if (!Symbol.Signal) { Symbol.Signal = Symbol('Signal') } const signalHandler = { get: (target, property, receiver) => { if (property===Symbol.xRay) { return target // don't notifyGet here, this is only called by set } if (property===Symbol.Signal) { return true } const value = target?.[property] // Reflect.get fails on a Set. notifyGet(receiver, property) if (typeof value === 'function') { if (Array.isArray(target)) { return (...args) => { let l = target.length // by binding the function to the receiver // all accesses in the function will be trapped // by the Proxy, so get/set/delete is all handled let result = value.apply(receiver, args) if (l != target.length) { notifySet(receiver, makeContext('length', { was: l, now: target.length }) ) } return result } } else if (target instanceof Set || target instanceof Map) { return (...args) => { // node doesn't allow you to call set/map functions // bound to the receiver.. so using target instead // there are no properties to update anyway, except for size let s = target.size let result = value.apply(target, args) if (s != target.size) { notifySet(receiver, makeContext( 'size', { was: s, now: target.size }) ) } // there is no efficient way to see if the function called // has actually changed the Set/Map, but by assuming the // 'setter' functions will change the results of the // 'getter' functions, effects should update correctly if (['set','add','clear','delete'].includes(property)) { notifySet(receiver, makeContext( { entries: {}, forEach: {}, has: {}, keys: {}, values: {}, [Symbol.iterator]: {} } ) ) } return result } } else if ( target instanceof HTMLElement || target instanceof Number || target instanceof String || target instanceof Boolean ) { return value.bind(target) } else { // support custom classes, hopefully return value.bind(receiver) } } if (value && typeof value == 'object') { //NOTE: get now returns a signal, set doesn't 'unsignal' the value set return signal(value) } return value }, set: (target, property, value, receiver) => { value = value?.[Symbol.xRay] || value // unwraps signal let current = target[property] if (current!==value) { target[property] = value notifySet(receiver, makeContext(property, { was: current, now: value } ) ) } if (typeof current === 'undefined') { notifySet(receiver, makeContext(iterate, {})) } return true }, has: (target, property) => { // receiver is not part of the has() call let receiver = signals.get(target) // so retrieve it here if (receiver) { notifyGet(receiver, property) } return Object.hasOwn(target, property) }, deleteProperty: (target, property) => { if (typeof target[property] !== 'undefined') { let current = target[property] delete target[property] let receiver = signals.get(target) // receiver is not part of the trap arguments, so retrieve it here notifySet(receiver, makeContext(property,{ delete: true, was: current })) } return true }, defineProperty: (target, property, descriptor) => { if (typeof target[property] === 'undefined') { let receiver = signals.get(target) // receiver is not part of the trap arguments, so retrieve it here notifySet(receiver, makeContext(iterate, {})) } return Object.defineProperty(target, property, descriptor) }, ownKeys: (target) => { let receiver = signals.get(target) // receiver is not part of the trap arguments, so retrieve it here notifyGet(receiver, iterate) return Reflect.ownKeys(target) } } /** * Keeps track of the return signal for an update function, as well * as signals connected to other objects. * Makes sure that a given object or function always uses the same * signal */ const signals = new WeakMap() /** * Creates a new signal proxy of the given object, that intercepts get/has and set/delete * to allow reactive functions to be triggered when signal values change. */ export function signal(v) { if (v[Symbol.Signal]) { // avoid wrapping a Signal inside a Signal let target = v[Symbol.xRay] if (!signals.has(target)) { signals.set(target, v) } v = target } else if (!signals.has(v)) { signals.set(v, new Proxy(v, signalHandler)) } return signals.get(v) } let batchedListeners = new Set() let batchMode = 0 /** * Called when a signal changes a property (set/delete) * Triggers any reactor function that depends on this signal * to re-compute its values */ function notifySet(self, context={}) { let listeners = [] context.forEach((change, property) => { let propListeners = getListeners(self, property) if (propListeners?.length) { for (let listener of propListeners) { addContext(listener, makeContext(property,change)) } listeners = listeners.concat(propListeners) } }) listeners = new Set(listeners.filter(Boolean)) if (listeners) { if (batchMode) { batchedListeners = batchedListeners.union(listeners) } else { const currentEffect = computeStack[computeStack.length-1] for (let listener of Array.from(listeners)) { if (listener!=currentEffect && listener?.needsUpdate) { listener() } clearContext(listener) } } } } function makeContext(property, change) { let context = new Map() if (typeof property === 'object') { for (let prop in property) { context.set(prop, property[prop]) } } else { context.set(property, change) } return context } function addContext(listener, context) { if (!listener.context) { listener.context = context } else { context.forEach((change,property)=> { listener.context.set(property, change) // TODO: merge change if needed }) } listener.needsUpdate = true } function clearContext(listener) { delete listener.context delete listener.needsUpdate } /** * Called when a signal property is accessed. If this happens * inside a reactor function--computeStack is not empty-- * then it adds the current reactor (top of this stack) to its * listeners. These are later called if this property changes */ function notifyGet(self, property) { let currentCompute = computeStack[computeStack.length-1] if (currentCompute) { // get was part of a react() function, so add it setListeners(self, property, currentCompute) } } /** * Keeps track of which update() functions are dependent on which * signal objects and which properties. Maps signals to update fns */ const listenersMap = new WeakMap() /** * Keeps track of which signals and properties are linked to which * update functions. Maps update functions and properties to signals */ const computeMap = new WeakMap() /** * Returns the update functions for a given signal and property */ function getListeners(self, property) { let listeners = listenersMap.get(self) return listeners ? Array.from(listeners.get(property) || []) : [] } /** * Adds an update function (compute) to the list of listeners on * the given signal (self) and property */ function setListeners(self, property, compute) { if (!listenersMap.has(self)) { listenersMap.set(self, new Map()) } let listeners = listenersMap.get(self) if (!listeners.has(property)) { listeners.set(property, new Set()) } listeners.get(property).add(compute) if (!computeMap.has(compute)) { computeMap.set(compute, new Map()) } let connectedSignals = computeMap.get(compute) if (!connectedSignals.has(property)) { connectedSignals.set(property, new Set) } connectedSignals.get(property).add(self) } /** * Removes alle listeners that trigger the given reactor function (compute) * This happens when a reactor is called, so that it can set new listeners * based on the current call (code path) */ function clearListeners(compute) { let connectedSignals = computeMap.get(compute) if (connectedSignals) { connectedSignals.forEach(property => { property.forEach(s => { let listeners = listenersMap.get(s) if (listeners.has(property)) { listeners.get(property).delete(compute) } }) }) } } /** * The top most entry is the currently running update function, used * to automatically record signals used in an update function. */ let computeStack = [] /** * Used for cycle detection: effectStack contains all running effect * functions. If the same function appears twice in this stack, there * is a recursive update call, which would cause an infinite loop. */ const effectStack = [] const effectMap = new WeakMap() /** * Used for cycle detection: signalStack contains all used signals. * If the same signal appears more than once, there is a cyclical * dependency between signals, which would cause an infinite loop. */ const signalStack = [] /** * Runs the given function at once, and then whenever a signal changes that * is used by the given function (or at least signals used in the previous run). */ export function effect(fn) { if (effectStack.findIndex(f => fn==f)!==-1) { throw new Error('Recursive update() call', {cause:fn}) } effectStack.push(fn) let connectedSignal = signals.get(fn) if (!connectedSignal) { connectedSignal = signal({ current: null }) signals.set(fn, connectedSignal) } // this is the function that is called automatically // whenever a signal dependency changes const computeEffect = function computeEffect() { if (signalStack.findIndex(s => s==connectedSignal)!==-1) { throw new Error('Cyclical dependency in update() call', { cause: fn}) } // remove all dependencies (signals) from previous runs clearListeners(computeEffect) // record new dependencies on this run computeStack.push(computeEffect) // prevent recursion signalStack.push(connectedSignal) // call the actual update function let result try { result = fn(computeEffect, computeStack, signalStack) } finally { // stop recording dependencies computeStack.pop() // stop the recursion prevention signalStack.pop() if (result instanceof Promise) { result.then((result) => { connectedSignal.current = result }) } else { connectedSignal.current = result } } } computeEffect.fn = fn effectMap.set(connectedSignal, computeEffect) // run the computEffect immediately upon creation computeEffect() return connectedSignal } export function destroy(connectedSignal) { // find the computeEffect associated with this signal const computeEffect = effectMap.get(connectedSignal)?.deref() if (!computeEffect) { return } // remove all listeners for this effect clearListeners(computeEffect) // remove all references to connectedSignal let fn = computeEffect.fn signals.remove(fn) effectMap.delete(connectedSignal) // if no other references to connectedSignal exist, it will be garbage collected } /** * Inside a batch() call, any changes to signals do not trigger effects * immediately. Instead, immediately after finishing the batch() call, * these effects will be called. Effects that are triggered by multiple * signals are called only once. * @param Function fn batch() calls this function immediately * @result mixed the result of the fn() function call */ export function batch(fn) { batchMode++ let result try { result = fn() } finally { if (result instanceof Promise) { result.then(() => { batchMode-- if (!batchMode) { runBatchedListeners() } }) } else { batchMode-- if (!batchMode) { runBatchedListeners() } } } return result } function runBatchedListeners() { let copyBatchedListeners = Array.from(batchedListeners) batchedListeners = new Set() const currentEffect = computeStack[computeStack.length-1] for (let listener of copyBatchedListeners) { if (listener!=currentEffect && listener?.needsUpdate) { listener() } clearContext(listener) } } /** * A throttledEffect is run immediately once. And then only once * per throttleTime (in ms). * @param Function fn the effect function to run whenever a signal changes * @param int throttleTime in ms * @returns signal with the result of the effect function fn */ export function throttledEffect(fn, throttleTime) { if (effectStack.findIndex(f => fn==f)!==-1) { throw new Error('Recursive update() call', {cause:fn}) } effectStack.push(fn) let connectedSignal = signals.get(fn) if (!connectedSignal) { connectedSignal = signal({ current: null }) signals.set(fn, connectedSignal) } let throttled = false let hasChange = true // this is the function that is called automatically // whenever a signal dependency changes const computeEffect = function computeEffect() { if (signalStack.findIndex(s => s==connectedSignal)!==-1) { throw new Error('Cyclical dependency in update() call', { cause: fn}) } if (throttled && throttled>Date.now()) { hasChange = true return } // remove all dependencies (signals) from previous runs clearListeners(computeEffect) // record new dependencies on this run computeStack.push(computeEffect) // prevent recursion signalStack.push(connectedSignal) // call the actual update function let result try { result = fn(computeEffect, computeStack, signalStack) } finally { hasChange = false // stop recording dependencies computeStack.pop() // stop the recursion prevention signalStack.pop() if (result instanceof Promise) { result.then((result) => { connectedSignal.current = result }) } else { connectedSignal.current = result } } throttled = Date.now()+throttleTime globalThis.setTimeout(() => { if (hasChange) { computeEffect() } }, throttleTime) } // run the computEffect immediately upon creation computeEffect() return connectedSignal } // refactor: Class clock() with an effect() method // keep track of effects per clock, and add clock property to the effect function // on notifySet add clock.effects to clock.needsUpdate list // on clock.tick() (or clock.time++) run only the clock.needsUpdate effects // (first create a copy and reset clock.needsUpdate, then run effects) export function clockEffect(fn, clock) { let connectedSignal = signals.get(fn) if (!connectedSignal) { connectedSignal = signal({ current: null }) signals.set(fn, connectedSignal) } let lastTick = -1 // clock.time should start at 0 or larger let hasChanged = true // make sure the first run goes through // this is the function that is called automatically // whenever a signal dependency changes const computeEffect = function computeEffect() { if (lastTick < clock.time) { if (hasChanged) { // remove all dependencies (signals) from previous runs clearListeners(computeEffect) // record new dependencies on this run computeStack.push(computeEffect) // make sure the clock.time signal is a dependency lastTick = clock.time // call the actual update function let result try { result = fn(computeEffect, computeStack) } finally { // stop recording dependencies computeStack.pop() if (result instanceof Promise) { result.then((result) => { connectedSignal.current = result }) } else { connectedSignal.current = result } hasChanged = false } } else { lastTick = clock.time } } else { hasChanged = true } } // run the computEffect immediately upon creation computeEffect() return connectedSignal } export function untracked(fn) { const remember = computeStack.slice() computeStack = [] try { return fn() } finally { computeStack = remember } }