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

476 wiersze
13 KiB
TypeScript
Czysty Zwykły widok Historia

/* eslint-disable prefer-rest-params */
import { ArraySet } from './ArraySet'
import { HistoryBuffer } from './HistoryBuffer'
import { maybeCaptureParent, startCapturingParents, stopCapturingParents } from './capture'
import { GLOBAL_START_EPOCH } from './constants'
import { EMPTY_ARRAY, equals, haveParentsChanged, singleton } from './helpers'
import { getGlobalEpoch } from './transactions'
import { Child, ComputeDiff, RESET_VALUE, Signal } from './types'
import { logComputedGetterWarning } from './warnings'
Use a global singleton for tlstate (#2322) One minor issue with signia is that it uses global state for bookkeeping, so it is potentially disastrous if there is more than one version of it included in a bundle. To prevent that being an issue before we had a warning that would trigger if signia detects multiple initializations. > Multiple versions of @tldraw/state detected. This will cause unexpected behavior. Please add "resolutions" (yarn/pnpm) or "overrides" (npm) in your package.json to ensure only one version of @tldraw/state is loaded. Alas I think this warning triggers too often in development environments, e.g. during HMR or janky bundlers. Something that can prevent the need for this particular warning is having a global singleton version of signia that we only instantiate once, and then re-use that one on subsequent module initializations. We didn't do this before because it has a few downsides: - breaks HMR if you are working on signia itself, since updated modules won't be used and you'll need to do a full refresh. - introduces the possibility of breakage if we remove or even add APIs to signia. We can't rely on having the latest version of signia be the first to instantiate, and we can't allow later instantiations to take precedence since atoms n stuff may have already been created with the prior version. To mitigate this I've introduced a `apiVersion` const that we can increment when we make any kind of additions or removals. If there is a mismatch between the `apiVersion` in the global singleton vs the currently-initializing module, then it throws. Ultimately i think the pros outweigh the cons here, i.e. far fewer people will see and have to deal with the error message shown above, and fewer people should encounter a situation where the editor appears to load but nothing changes when you interact with it. ### Change Type - [x] `patch` — Bug fix [^1]: publishes a `patch` release, for devDependencies use `internal` [^2]: will not publish a new version ### Release Notes - Make a global singleton for tlstate.
2023-12-14 13:35:34 +00:00
/**
* @public
*/
export const UNINITIALIZED = Symbol.for('com.tldraw.state/UNINITIALIZED')
/**
* The type of the first value passed to a computed signal function as the 'prevValue' parameter.
*
* @see [[isUninitialized]].
* @public
*/
Use a global singleton for tlstate (#2322) One minor issue with signia is that it uses global state for bookkeeping, so it is potentially disastrous if there is more than one version of it included in a bundle. To prevent that being an issue before we had a warning that would trigger if signia detects multiple initializations. > Multiple versions of @tldraw/state detected. This will cause unexpected behavior. Please add "resolutions" (yarn/pnpm) or "overrides" (npm) in your package.json to ensure only one version of @tldraw/state is loaded. Alas I think this warning triggers too often in development environments, e.g. during HMR or janky bundlers. Something that can prevent the need for this particular warning is having a global singleton version of signia that we only instantiate once, and then re-use that one on subsequent module initializations. We didn't do this before because it has a few downsides: - breaks HMR if you are working on signia itself, since updated modules won't be used and you'll need to do a full refresh. - introduces the possibility of breakage if we remove or even add APIs to signia. We can't rely on having the latest version of signia be the first to instantiate, and we can't allow later instantiations to take precedence since atoms n stuff may have already been created with the prior version. To mitigate this I've introduced a `apiVersion` const that we can increment when we make any kind of additions or removals. If there is a mismatch between the `apiVersion` in the global singleton vs the currently-initializing module, then it throws. Ultimately i think the pros outweigh the cons here, i.e. far fewer people will see and have to deal with the error message shown above, and fewer people should encounter a situation where the editor appears to load but nothing changes when you interact with it. ### Change Type - [x] `patch` — Bug fix [^1]: publishes a `patch` release, for devDependencies use `internal` [^2]: will not publish a new version ### Release Notes - Make a global singleton for tlstate.
2023-12-14 13:35:34 +00:00
export type UNINITIALIZED = typeof UNINITIALIZED
/**
* Call this inside a computed signal function to determine whether it is the first time the function is being called.
*
* Mainly useful for incremental signal computation.
*
* @example
* ```ts
* const count = atom('count', 0)
* const double = computed('double', (prevValue) => {
* if (isUninitialized(prevValue)) {
[improvement] More selection logic (#1806) This PR includes further UX improvements to selection. - clicking inside of a hollow shape will no longer select it on pointer up - clicking a shape's filled label will select it on pointer down - clicking a shape's empty label will select it on pointer up - clicking and dragging a selected arrow is now better limited to its body, not its bounds - arrows will no longer bind to labels ### Text labels A big change here relates to text labels. Previously, we had listeners set on the text label elements; I've removed these and we now check the actual label bounds geometry for a hit. For geo shapes, this geometry is now placed correctly based on the alignment / vertical alignment of the label. - Clicking on a label with text in it will select the shape on pointer down. - Clicking on an empty text label will select the shape on pointer up. ## Hollow shapes Previously, shapes with `fill: none` were also being selected on pointer up. I've removed that logic because it was producing wrong-feeling selections too often. We now select these shapes only when clicking on the label (as mentioned above) or when clicking on the edges of the shape. This is in line with the original behavior (currently on tldraw.com, prior to the earlier PR that updated selection logic). ## Arrows Arrows still hit the inside of hollow shapes, using the "smallest hovered" logic previously used for pointer-up selection on hollow shapes. They also now correctly do so while ignoring text labels. ### Change Type - [x] `minor` — New feature ### Test Plan 1. try selecting geo shapes, nested geo shapes, arrows and shapes with labels or without labels - [x] Unit Tests
2023-08-13 15:55:24 +00:00
* print('First time!')
* }
* return count.get() * 2
* })
* ```
*
* @param value - The value to check.
* @public
*/
export const isUninitialized = (value: any): value is UNINITIALIZED => {
return value === UNINITIALIZED
}
export const WithDiff = singleton(
'WithDiff',
() =>
class WithDiff<Value, Diff> {
constructor(
public value: Value,
public diff: Diff
) {}
}
)
export type WithDiff<Value, Diff> = { value: Value; diff: Diff }
/**
* When writing incrementally-computed signals it is convenient (and usually more performant) to incrementally compute the diff too.
*
* You can use this function to wrap the return value of a computed signal function to indicate that the diff should be used instead of calculating a new one with [[AtomOptions.computeDiff]].
*
* @example
* ```ts
* const count = atom('count', 0)
* const double = computed('double', (prevValue) => {
* const nextValue = count.get() * 2
* if (isUninitialized(prevValue)) {
* return nextValue
* }
* return withDiff(nextValue, nextValue - prevValue)
* }, { historyLength: 10 })
* ```
*
*
* @param value - The value.
* @param diff - The diff.
* @public
*/
export function withDiff<Value, Diff>(value: Value, diff: Diff): WithDiff<Value, Diff> {
return new WithDiff(value, diff)
}
/**
* Options for creating computed signals. Used when calling [[computed]].
* @public
*/
export interface ComputedOptions<Value, Diff> {
/**
* The maximum number of diffs to keep in the history buffer.
*
* If you don't need to compute diffs, or if you will supply diffs manually via [[Atom.set]], you can leave this as `undefined` and no history buffer will be created.
*
* If you expect the value to be part of an active effect subscription all the time, and to not change multiple times inside of a single transaction, you can set this to a relatively low number (e.g. 10).
*
* Otherwise, set this to a higher number based on your usage pattern and memory constraints.
*
*/
historyLength?: number
/**
* A method used to compute a diff between the atom's old and new values. If provided, it will not be used unless you also specify [[AtomOptions.historyLength]].
*/
computeDiff?: ComputeDiff<Value, Diff>
/**
* If provided, this will be used to compare the old and new values of the atom to determine if the value has changed.
* By default, values are compared using first using strict equality (`===`), then `Object.is`, and finally any `.equals` method present in the object's prototype chain.
* @param a - The old value
* @param b - The new value
* @returns
*/
isEqual?: (a: any, b: any) => boolean
}
/**
* A computed signal created via [[computed]].
*
* @public
*/
export interface Computed<Value, Diff = unknown> extends Signal<Value, Diff> {
/**
* Whether this computed child is involved in an actively-running effect graph.
* @public
*/
readonly isActivelyListening: boolean
/** @internal */
readonly parentSet: ArraySet<Signal<any, any>>
/** @internal */
readonly parents: Signal<any, any>[]
/** @internal */
readonly parentEpochs: number[]
}
/**
* @internal
*/
class __UNSAFE__Computed<Value, Diff = unknown> implements Computed<Value, Diff> {
lastChangedEpoch = GLOBAL_START_EPOCH
lastTraversedEpoch = GLOBAL_START_EPOCH
/**
* The epoch when the reactor was last checked.
*/
private lastCheckedEpoch = GLOBAL_START_EPOCH
parentSet = new ArraySet<Signal<any, any>>()
parents: Signal<any, any>[] = []
parentEpochs: number[] = []
children = new ArraySet<Child>()
// eslint-disable-next-line no-restricted-syntax
get isActivelyListening(): boolean {
return !this.children.isEmpty
}
historyBuffer?: HistoryBuffer<Diff>
// The last-computed value of this signal.
private state: Value = UNINITIALIZED as unknown as Value
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
// If the signal throws an error we stash it so we can rethrow it on the next get()
private error: null | { thrownValue: any } = null
private computeDiff?: ComputeDiff<Value, Diff>
private readonly isEqual: (a: any, b: any) => boolean
constructor(
/**
* The name of the signal. This is used for debugging and performance profiling purposes. It does not need to be globally unique.
*/
public readonly name: string,
/**
* The function that computes the value of the signal.
*/
private readonly derive: (
previousValue: Value | UNINITIALIZED,
lastComputedEpoch: number
) => Value | WithDiff<Value, Diff>,
options?: ComputedOptions<Value, Diff>
) {
if (options?.historyLength) {
this.historyBuffer = new HistoryBuffer(options.historyLength)
}
this.computeDiff = options?.computeDiff
this.isEqual = options?.isEqual ?? equals
}
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
__unsafe__getWithoutCapture(ignoreErrors?: boolean): Value {
const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH
if (!isNew && (this.lastCheckedEpoch === getGlobalEpoch() || !haveParentsChanged(this))) {
this.lastCheckedEpoch = getGlobalEpoch()
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
if (this.error) {
if (!ignoreErrors) {
throw this.error.thrownValue
} else {
return this.state // will be UNINITIALIZED
}
} else {
return this.state
}
}
try {
startCapturingParents(this)
const result = this.derive(this.state, this.lastCheckedEpoch)
const newState = result instanceof WithDiff ? result.value : result
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
const isUninitialized = this.state === UNINITIALIZED
if (isUninitialized || !this.isEqual(newState, this.state)) {
if (this.historyBuffer && !isUninitialized) {
const diff = result instanceof WithDiff ? result.diff : undefined
this.historyBuffer.pushEntry(
this.lastChangedEpoch,
getGlobalEpoch(),
diff ??
this.computeDiff?.(this.state, newState, this.lastCheckedEpoch, getGlobalEpoch()) ??
RESET_VALUE
)
}
this.lastChangedEpoch = getGlobalEpoch()
this.state = newState
}
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
this.error = null
this.lastCheckedEpoch = getGlobalEpoch()
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
return this.state
} catch (e) {
// if a derived value throws an error, we reset the state to UNINITIALIZED
if (this.state !== UNINITIALIZED) {
this.state = UNINITIALIZED as unknown as Value
this.lastChangedEpoch = getGlobalEpoch()
}
this.lastCheckedEpoch = getGlobalEpoch()
// we also clear the history buffer if an error was thrown
if (this.historyBuffer) {
this.historyBuffer.clear()
}
this.error = { thrownValue: e }
// we don't wish to propagate errors when derefed via haveParentsChanged()
if (!ignoreErrors) throw e
return this.state
} finally {
stopCapturingParents()
}
}
get(): Value {
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
try {
return this.__unsafe__getWithoutCapture()
} finally {
// if the deriver throws an error we still need to capture
maybeCaptureParent(this)
}
}
getDiffSince(epoch: number): RESET_VALUE | Diff[] {
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
// we can ignore any errors thrown during derive
this.__unsafe__getWithoutCapture(true)
// and we still need to capture this signal as a parent
maybeCaptureParent(this)
if (epoch >= this.lastChangedEpoch) {
return EMPTY_ARRAY
}
return this.historyBuffer?.getChangesSince(epoch) ?? RESET_VALUE
}
}
export const _Computed = singleton('Computed', () => __UNSAFE__Computed)
export type _Computed = InstanceType<typeof __UNSAFE__Computed>
function computedMethodAnnotation(
options: ComputedOptions<any, any> = {},
_target: any,
key: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value
const derivationKey = Symbol.for('__@tldraw/state__computed__' + key)
descriptor.value = function (this: any) {
let d = this[derivationKey] as Computed<any> | undefined
if (!d) {
d = new _Computed(key, originalMethod!.bind(this) as any, options)
Object.defineProperty(this, derivationKey, {
enumerable: false,
configurable: false,
writable: false,
value: d,
})
}
return d.get()
}
descriptor.value[isComputedMethodKey] = true
return descriptor
}
function computedAnnotation(
options: ComputedOptions<any, any> = {},
_target: any,
key: string,
descriptor: PropertyDescriptor
) {
if (descriptor.get) {
logComputedGetterWarning()
return computedGetterAnnotation(options, _target, key, descriptor)
} else {
return computedMethodAnnotation(options, _target, key, descriptor)
}
}
function computedGetterAnnotation(
options: ComputedOptions<any, any> = {},
_target: any,
key: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.get
const derivationKey = Symbol.for('__@tldraw/state__computed__' + key)
descriptor.get = function (this: any) {
let d = this[derivationKey] as Computed<any> | undefined
if (!d) {
d = new _Computed(key, originalMethod!.bind(this) as any, options)
Object.defineProperty(this, derivationKey, {
enumerable: false,
configurable: false,
writable: false,
value: d,
})
}
return d.get()
}
return descriptor
}
const isComputedMethodKey = '@@__isComputedMethod__@@'
/**
* Retrieves the underlying computed instance for a given property created with the [[computed]]
* decorator.
*
* @example
* ```ts
* class Counter {
* max = 100
* count = atom(0)
*
* @computed getRemaining() {
* return this.max - this.count.get()
* }
* }
*
* const c = new Counter()
* const remaining = getComputedInstance(c, 'getRemaining')
* remaining.get() === 100 // true
* c.count.set(13)
* remaining.get() === 87 // true
* ```
*
* @param obj - The object
* @param propertyName - The property name
* @public
*/
export function getComputedInstance<Obj extends object, Prop extends keyof Obj>(
obj: Obj,
propertyName: Prop
): Computed<Obj[Prop]> {
const key = Symbol.for('__@tldraw/state__computed__' + propertyName.toString())
let inst = obj[key as keyof typeof obj] as Computed<Obj[Prop]> | undefined
if (!inst) {
// deref to make sure it exists first
const val = obj[propertyName]
if (typeof val === 'function' && (val as any)[isComputedMethodKey]) {
val.call(obj)
}
inst = obj[key as keyof typeof obj] as Computed<Obj[Prop]> | undefined
}
return inst as any
}
/**
* Creates a computed signal.
*
* @example
* ```ts
* const name = atom('name', 'John')
* const greeting = computed('greeting', () => `Hello ${name.get()}!`)
* console.log(greeting.get()) // 'Hello John!'
* ```
*
* `computed` may also be used as a decorator for creating computed getter methods.
*
* @example
* ```ts
* class Counter {
* max = 100
* count = atom<number>(0)
*
* @computed getRemaining() {
* return this.max - this.count.get()
* }
* }
* ```
*
* You may optionally pass in a [[ComputedOptions]] when used as a decorator:
*
* @example
* ```ts
* class Counter {
* max = 100
* count = atom<number>(0)
*
* @computed({isEqual: (a, b) => a === b})
* getRemaining() {
* return this.max - this.count.get()
* }
* }
* ```
*
* @param name - The name of the signal.
* @param compute - The function that computes the value of the signal.
* @param options - Options for the signal.
*
* @public
*/
export function computed<Value, Diff = unknown>(
name: string,
compute: (
previousValue: Value | typeof UNINITIALIZED,
lastComputedEpoch: number
) => Value | WithDiff<Value, Diff>,
options?: ComputedOptions<Value, Diff>
): Computed<Value, Diff>
/** @public */
export function computed(
target: any,
key: string,
descriptor: PropertyDescriptor
): PropertyDescriptor
/** @public */
export function computed<Value, Diff = unknown>(
options?: ComputedOptions<Value, Diff>
): (target: any, key: string, descriptor: PropertyDescriptor) => PropertyDescriptor
/** @public */
export function computed() {
if (arguments.length === 1) {
const options = arguments[0]
return (target: any, key: string, descriptor: PropertyDescriptor) =>
computedAnnotation(options, target, key, descriptor)
} else if (typeof arguments[0] === 'string') {
return new _Computed(arguments[0], arguments[1], arguments[2])
} else {
return computedAnnotation(undefined, arguments[0], arguments[1], arguments[2])
}
}
/**
* Returns true if the given value is a computed signal.
*
* @param value
* @returns {value is Computed<any>}
* @public
*/
export function isComputed(value: any): value is Computed<any> {
return value && value instanceof _Computed
}