import { attach, detach, singleton } from './helpers' import type { Child, Signal } from './types' class CaptureStackFrame { offset = 0 numNewParents = 0 maybeRemoved?: Signal[] constructor( public readonly below: CaptureStackFrame | null, public readonly child: Child ) {} } const inst = singleton('capture', () => ({ stack: null as null | CaptureStackFrame })) /** * Executes the given function without capturing any parents in the current capture context. * * This is mainly useful if you want to run an effect only when certain signals change while also * dereferencing other signals which should not cause the effect to rerun on their own. * * @example * ```ts * const name = atom('name', 'Sam') * const time = atom('time', () => new Date().getTime()) * * setInterval(() => { * time.set(new Date().getTime()) * }) * * react('log name changes', () => { * print(name.get(), 'was changed at', unsafe__withoutCapture(() => time.get())) * }) * * ``` * * @public */ export function unsafe__withoutCapture(fn: () => T): T { const oldStack = inst.stack inst.stack = null try { return fn() } finally { inst.stack = oldStack } } export function startCapturingParents(child: Child) { inst.stack = new CaptureStackFrame(inst.stack, child) } export function stopCapturingParents() { const frame = inst.stack! inst.stack = frame.below const didParentsChange = frame.numNewParents > 0 || frame.offset !== frame.child.parents.length if (!didParentsChange) { return } for (let i = frame.offset; i < frame.child.parents.length; i++) { const p = frame.child.parents[i] const parentWasRemoved = frame.child.parents.indexOf(p) >= frame.offset if (parentWasRemoved) { detach(p, frame.child) } } frame.child.parents.length = frame.offset frame.child.parentEpochs.length = frame.offset if (inst.stack?.maybeRemoved) { for (let i = 0; i < inst.stack.maybeRemoved.length; i++) { const maybeRemovedParent = inst.stack.maybeRemoved[i] if (frame.child.parents.indexOf(maybeRemovedParent) === -1) { detach(maybeRemovedParent, frame.child) } } } } // this must be called after the parent is up to date export function maybeCaptureParent(p: Signal) { if (inst.stack) { const idx = inst.stack.child.parents.indexOf(p) // if the child didn't deref this parent last time it executed, then idx will be -1 // if the child did deref this parent last time but in a different order relative to other parents, then idx will be greater than stack.offset // if the child did deref this parent last time in the same order, then idx will be the same as stack.offset // if the child did deref this parent already during this capture session then 0 <= idx < stack.offset if (idx < 0) { inst.stack.numNewParents++ if (inst.stack.child.isActivelyListening) { attach(p, inst.stack.child) } } if (idx < 0 || idx >= inst.stack.offset) { if (idx !== inst.stack.offset && idx > 0) { const maybeRemovedParent = inst.stack.child.parents[inst.stack.offset] if (!inst.stack.maybeRemoved) { inst.stack.maybeRemoved = [maybeRemovedParent] } else if (inst.stack.maybeRemoved.indexOf(maybeRemovedParent) === -1) { inst.stack.maybeRemoved.push(maybeRemovedParent) } } inst.stack.child.parents[inst.stack.offset] = p inst.stack.child.parentEpochs[inst.stack.offset] = p.lastChangedEpoch inst.stack.offset++ } } } /** * A debugging tool that tells you why a computed signal or effect is running. * Call in the body of a computed signal or effect function. * * @example * ```ts * const name = atom('name', 'Bob') * react('greeting', () => { * whyAmIRunning() * print('Hello', name.get()) * }) * * name.set('Alice') * * // 'greeting' is running because: * // 'name' changed => 'Alice' * ``` * * @public */ export function whyAmIRunning() { const child = inst.stack?.child if (!child) { throw new Error('whyAmIRunning() called outside of a reactive context') } const changedParents = [] for (let i = 0; i < child.parents.length; i++) { const parent = child.parents[i] if (parent.lastChangedEpoch > child.parentEpochs[i]) { changedParents.push(parent) } } if (changedParents.length === 0) { // eslint-disable-next-line no-console console.log((child as any).name, 'is running but none of the parents changed') } else { // eslint-disable-next-line no-console console.log((child as any).name, 'is running because:') for (const changedParent of changedParents) { // eslint-disable-next-line no-console console.log( '\t', (changedParent as any).name, 'changed =>', changedParent.__unsafe__getWithoutCapture(true) ) } } }