Tldraw/packages/state/src/lib/core/capture.ts

172 wiersze
4.6 KiB
TypeScript

import { attach, detach, singleton } from './helpers'
import type { Child, Signal } from './types'
class CaptureStackFrame {
offset = 0
numNewParents = 0
maybeRemoved?: Signal<any>[]
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<T>(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<any, any>) {
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)
)
}
}
}