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

172 wiersze
4.6 KiB
TypeScript
Czysty Zwykły widok Historia

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 =>',
Improve signia error handling (#2835) This PR revamps how errors in signia are handled. This was brought about by a situation that @MitjaBezensek encountered where he added a reactor to a shape util class. During fuzz tests, that reactor was being executed at times when the Editor was not in a usable state (we had a minor hole in our sync rebase logic that allowed this, fixed elsewhere) and the reactor was throwing errors because it dereferenced a parent signal that relied on the page state (getShapesInCurrentPage or whatever) when there were no page records in the store. The strange part was that even if we wrapped the body of the reactor function in a try/catch, ignoring the error, we'd still see the error bubble up somehow. That was because the error was being thrown in a Computed derive function, and those are evaluated independently (i.e. outside of the reactor function) by signia as it traverses the dependency graph from leaves to roots in the `haveParentsChanged()` internal function. So the immediate fix was to make it so that `haveParentsChanged` ignores errors somehow. But the better fix involved completely revamping how signia handles errors, and they work very much like how signia handles values now. i.e. - signia still assumes that deriver functions are pure, and that if a deriver function throws once it will throw again unless its parent signals change value, so **it caches thrown errors for computed values** and throws them again if .get() is called again before the parents change - it clears the history buffer if an error is thrown - it does not allow errors to bubble during dirty checking i.e. inside `haveParentsChanged` or while calculating diffs. ### Change Type - [x] `patch` — Bug fix - [ ] `minor` — New feature - [ ] `major` — Breaking change - [ ] `dependencies` — Changes to package dependencies[^1] - [ ] `documentation` — Changes to the documentation only[^2] - [ ] `tests` — Changes to any test code only[^2] - [ ] `internal` — Any other changes that don't affect the published package[^2] - [ ] I don't know [^1]: publishes a `patch` release, for devDependencies use `internal` [^2]: will not publish a new version ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [x] Unit Tests - [ ] End to end tests ### Release Notes - Add a brief release note for your PR here.
2024-02-14 13:32:15 +00:00
changedParent.__unsafe__getWithoutCapture(true)
)
}
}
}