@tldraw/state 5.3.2 → 5.4.0-canary.02cd0bd3b597

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/DOCS.md +64 -63
  2. package/README.md +35 -36
  3. package/dist-cjs/index.d.ts +26 -27
  4. package/dist-cjs/index.js +1 -1
  5. package/dist-cjs/lib/ArraySet.js +47 -144
  6. package/dist-cjs/lib/ArraySet.js.map +2 -2
  7. package/dist-cjs/lib/Atom.js +12 -26
  8. package/dist-cjs/lib/Atom.js.map +2 -2
  9. package/dist-cjs/lib/Computed.js +36 -64
  10. package/dist-cjs/lib/Computed.js.map +2 -2
  11. package/dist-cjs/lib/EffectScheduler.js +1 -1
  12. package/dist-cjs/lib/EffectScheduler.js.map +2 -2
  13. package/dist-cjs/lib/HistoryBuffer.js +8 -8
  14. package/dist-cjs/lib/HistoryBuffer.js.map +2 -2
  15. package/dist-cjs/lib/capture.js +1 -3
  16. package/dist-cjs/lib/capture.js.map +2 -2
  17. package/dist-cjs/lib/constants.js.map +2 -2
  18. package/dist-cjs/lib/helpers.js +3 -11
  19. package/dist-cjs/lib/helpers.js.map +2 -2
  20. package/dist-cjs/lib/localStorageAtom.js +7 -2
  21. package/dist-cjs/lib/localStorageAtom.js.map +2 -2
  22. package/dist-cjs/lib/transactions.js +11 -19
  23. package/dist-cjs/lib/transactions.js.map +2 -2
  24. package/dist-cjs/lib/types.js.map +1 -1
  25. package/dist-cjs/lib/warnings.js +2 -4
  26. package/dist-cjs/lib/warnings.js.map +2 -2
  27. package/dist-esm/index.d.mts +26 -27
  28. package/dist-esm/index.mjs +1 -1
  29. package/dist-esm/lib/ArraySet.mjs +47 -144
  30. package/dist-esm/lib/ArraySet.mjs.map +2 -2
  31. package/dist-esm/lib/Atom.mjs +12 -26
  32. package/dist-esm/lib/Atom.mjs.map +2 -2
  33. package/dist-esm/lib/Computed.mjs +36 -64
  34. package/dist-esm/lib/Computed.mjs.map +2 -2
  35. package/dist-esm/lib/EffectScheduler.mjs +1 -1
  36. package/dist-esm/lib/EffectScheduler.mjs.map +2 -2
  37. package/dist-esm/lib/HistoryBuffer.mjs +8 -8
  38. package/dist-esm/lib/HistoryBuffer.mjs.map +2 -2
  39. package/dist-esm/lib/capture.mjs +1 -3
  40. package/dist-esm/lib/capture.mjs.map +2 -2
  41. package/dist-esm/lib/constants.mjs.map +2 -2
  42. package/dist-esm/lib/helpers.mjs +3 -11
  43. package/dist-esm/lib/helpers.mjs.map +2 -2
  44. package/dist-esm/lib/localStorageAtom.mjs +7 -2
  45. package/dist-esm/lib/localStorageAtom.mjs.map +2 -2
  46. package/dist-esm/lib/transactions.mjs +11 -19
  47. package/dist-esm/lib/transactions.mjs.map +2 -2
  48. package/dist-esm/lib/types.mjs.map +1 -1
  49. package/dist-esm/lib/warnings.mjs +2 -4
  50. package/dist-esm/lib/warnings.mjs.map +2 -2
  51. package/package.json +2 -2
  52. package/src/lib/ArraySet.ts +68 -176
  53. package/src/lib/Atom.ts +27 -31
  54. package/src/lib/Computed.ts +68 -96
  55. package/src/lib/EffectScheduler.ts +9 -8
  56. package/src/lib/HistoryBuffer.ts +12 -10
  57. package/src/lib/__tests__/ArraySet.test.ts +39 -13
  58. package/src/lib/__tests__/EffectScheduler.test.ts +18 -0
  59. package/src/lib/__tests__/HistoryBuffer.test.ts +6 -3
  60. package/src/lib/__tests__/computed.test.ts +75 -0
  61. package/src/lib/__tests__/errors.test.ts +24 -0
  62. package/src/lib/__tests__/helpers.test.ts +7 -11
  63. package/src/lib/__tests__/history.test.ts +32 -2
  64. package/src/lib/__tests__/localStorageAtom.test.ts +15 -0
  65. package/src/lib/capture.ts +13 -13
  66. package/src/lib/constants.ts +3 -22
  67. package/src/lib/helpers.ts +15 -140
  68. package/src/lib/localStorageAtom.ts +9 -2
  69. package/src/lib/transactions.ts +23 -47
  70. package/src/lib/types.ts +7 -7
  71. package/src/lib/warnings.ts +2 -10
@@ -1,8 +1,9 @@
1
+ import { exhaustiveSwitchError } from '@tldraw/utils'
1
2
  import { vi } from 'vitest'
2
3
  import { atom } from '../Atom'
3
4
  import { Computed, UNINITIALIZED, computed, isUninitialized, withDiff } from '../Computed'
4
5
  import { react } from '../EffectScheduler'
5
- import { EMPTY_ARRAY, assertNever } from '../helpers'
6
+ import { EMPTY_ARRAY } from '../helpers'
6
7
  import { getGlobalEpoch, transact, transaction } from '../transactions'
7
8
  import { RESET_VALUE, Signal } from '../types'
8
9
 
@@ -78,6 +79,35 @@ describe('atom history (H)', () => {
78
79
  expect(calls).toEqual([[1, 5, epochBeforeSet, getGlobalEpoch()]])
79
80
  })
80
81
 
82
+ it('[H2][C2] a computed read inside computeDiff still sees the change afterwards', () => {
83
+ // computeDiff runs before the epoch ticks, so a dependent computed it reads is not stamped as
84
+ // checked at the epoch of the write that is still in progress.
85
+ const a = atom('', 1, {
86
+ historyLength: 10,
87
+ computeDiff: (prev, next) => {
88
+ c.get()
89
+ return next - prev
90
+ },
91
+ })
92
+ const c: Computed<number> = computed('', () => a.get() * 2)
93
+ const seen: number[] = []
94
+ react('', () => seen.push(c.get()))
95
+
96
+ a.set(2)
97
+
98
+ expect(c.get()).toBe(4)
99
+ expect(seen).toEqual([2, 4])
100
+ expect(a.getDiffSince(a.lastChangedEpoch - 1)).toEqual([1])
101
+ })
102
+
103
+ it('[H2] records an explicit null diff rather than treating it as missing', () => {
104
+ const a = atom<number, null | number>('', 1, { historyLength: 3 })
105
+ const startEpoch = a.lastChangedEpoch
106
+ a.set(2, 1)
107
+ a.set(3, null)
108
+ expect(a.getDiffSince(startEpoch)).toEqual([1, null])
109
+ })
110
+
81
111
  it('[H4] clears the history buffer if no diff can be determined', () => {
82
112
  const a = atom('', 1, { historyLength: 3 })
83
113
  const startEpoch = getGlobalEpoch()
@@ -383,7 +413,7 @@ function getIncrementalRecordMapper<In, Out>(
383
413
  }
384
414
  break
385
415
  default:
386
- assertNever(change)
416
+ exhaustiveSwitchError(change)
387
417
  }
388
418
  }
389
419
 
@@ -260,4 +260,19 @@ describe('localStorageAtom', () => {
260
260
  cleanup2()
261
261
  })
262
262
  })
263
+
264
+ describe('outside the browser', () => {
265
+ it('[LS6] does not throw without a window and returns a working atom', () => {
266
+ vi.stubGlobal('window', undefined)
267
+ try {
268
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
269
+ expect(atom.get()).toBe('initial-value')
270
+ atom.set('next')
271
+ expect(atom.get()).toBe('next')
272
+ expect(() => cleanup()).not.toThrow()
273
+ } finally {
274
+ vi.unstubAllGlobals()
275
+ }
276
+ })
277
+ })
263
278
  })
@@ -24,10 +24,10 @@ const inst = singleton('capture', () => ({ stack: null as null | CaptureStackFra
24
24
  * @example
25
25
  * ```ts
26
26
  * const name = atom('name', 'Sam')
27
- * const time = atom('time', () => new Date().getTime())
27
+ * const time = atom('time', Date.now())
28
28
  *
29
29
  * setInterval(() => {
30
- * time.set(new Date().getTime())
30
+ * time.set(Date.now())
31
31
  * })
32
32
  *
33
33
  * react('log name changes', () => {
@@ -149,21 +149,20 @@ export function stopCapturingParents() {
149
149
  */
150
150
  export function maybeCaptureParent(p: Signal<any, any>) {
151
151
  if (inst.stack) {
152
- const wasCapturedAlready = inst.stack.child.parentSet.has(p)
153
- // if the child didn't deref this parent last time it executed, then idx will be -1
154
- // 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
155
- // if the child did deref this parent last time in the same order, then idx will be the same as stack.offset
156
- // if the child did deref this parent already during this capture session then 0 <= idx < stack.offset
157
-
158
- if (wasCapturedAlready) {
152
+ // `add` returns false when the parent was already captured this run. In array mode both
153
+ // `has` and `add` scan with indexOf, so going straight to `add` halves the scans per
154
+ // captured parent.
155
+ if (!inst.stack.child.parentSet.add(p)) {
159
156
  return
160
157
  }
161
158
 
162
- inst.stack.child.parentSet.add(p)
163
159
  if (inst.stack.child.isActivelyListening) {
164
160
  attach(p, inst.stack.child)
165
161
  }
166
162
 
163
+ // Parents are recorded in deref order. If the slot at this offset held a different parent
164
+ // last run, that parent may have moved later in the order or been dropped; only
165
+ // stopCapturingParents can tell, so remember it for then.
167
166
  if (inst.stack.offset < inst.stack.child.parents.length) {
168
167
  const maybeRemovedParent = inst.stack.child.parents[inst.stack.offset]
169
168
  if (maybeRemovedParent !== p) {
@@ -183,7 +182,8 @@ export function maybeCaptureParent(p: Signal<any, any>) {
183
182
 
184
183
  /**
185
184
  * A debugging tool that tells you why a computed signal or effect is running.
186
- * Call in the body of a computed signal or effect function.
185
+ * Call in the body of a computed signal or effect function. Nothing is logged for the run that
186
+ * calls it; from the next run on, each run logs the ancestors that changed.
187
187
  *
188
188
  * @example
189
189
  * ```ts
@@ -195,8 +195,8 @@ export function maybeCaptureParent(p: Signal<any, any>) {
195
195
  *
196
196
  * name.set('Alice')
197
197
  *
198
- * // 'greeting' is running because:
199
- * // 'name' changed => 'Alice'
198
+ * // Effect(greeting) is executing because:
199
+ * // ↳ Atom(name) changed
200
200
  * ```
201
201
  *
202
202
  * @public
@@ -1,26 +1,7 @@
1
1
  /**
2
- * The initial epoch value used to mark derivations (computed signals and effects) as dirty before their first computation.
2
+ * Sentinel below any real epoch. Derived signals and effects start with their epochs set to this
3
+ * so they are dirty until their first run; real epochs start at `GLOBAL_START_EPOCH + 1`.
3
4
  *
4
- * This constant ensures that all computed signals and effects start in a "dirty" state, guaranteeing they will
5
- * be computed/executed at least once when first accessed or started. The value -1 is used because:
6
- * - Global epoch starts at 0 (GLOBAL_START_EPOCH + 1)
7
- * - Any derived signal initialized with GLOBAL_START_EPOCH (-1) will be considered dirty when compared to any positive epoch
8
- * - This forces initial computation/execution without requiring special initialization logic
9
- *
10
- * Used by:
11
- * - Computed signals to track when they were last changed
12
- * - Effect schedulers to track when they were last executed
13
- * - Transaction system for initial global and reaction epoch values
14
- *
15
- * @example
16
- * ```ts
17
- * // In Computed class constructor
18
- * lastChangedEpoch = GLOBAL_START_EPOCH // -1, marking as dirty
19
- *
20
- * // When global epoch is 5, this computed will be dirty since -1 < 5
21
- * const needsComputation = this.lastChangedEpoch < globalEpoch
22
- * ```
23
- *
24
- * @public
5
+ * @internal
25
6
  */
26
7
  export const GLOBAL_START_EPOCH = -1
@@ -1,42 +1,23 @@
1
1
  import { Child, Signal } from './types'
2
2
 
3
- /**
4
- * Get whether the given value is a child.
5
- *
6
- * @param x The value to check.
7
- * @returns True if the value is a child, false otherwise.
8
- * @internal
9
- */
10
3
  function isChild(x: any): x is Child {
11
4
  return x && typeof x === 'object' && 'parents' in x
12
5
  }
13
6
 
14
7
  /**
15
- * Checks if any of a child's parent signals have changed by comparing their current epochs
16
- * with the child's cached view of those epochs.
17
- *
18
- * This function is used internally to determine if a computed signal or effect needs to
19
- * be re-evaluated because one of its dependencies has changed.
8
+ * Whether any of the child's parents changed since the child last recorded their epochs. O(parents);
9
+ * returns at the first changed parent, so parents after it are not brought up to date.
20
10
  *
21
- * @param child - The child (computed signal or effect) to check for parent changes
22
- * @returns `true` if any parent signal has changed since the child last observed it, `false` otherwise
23
- * @example
24
- * ```ts
25
- * const childSignal = computed('child', () => parentAtom.get())
26
- * // Check if the child needs to recompute
27
- * if (haveParentsChanged(childSignal)) {
28
- * // Recompute the child's value
29
- * }
30
- * ```
31
11
  * @internal
32
12
  */
33
13
  export function haveParentsChanged(child: Child): boolean {
34
14
  for (let i = 0, n = child.parents.length; i < n; i++) {
35
- // Get the parent's value without capturing it.
36
- child.parents[i].__unsafe__getWithoutCapture(true)
15
+ const parent = child.parents[i]
16
+ // Bring the parent up to date first: a computed parent's `lastChangedEpoch` only moves when
17
+ // it is re-derived.
18
+ parent.__unsafe__getWithoutCapture(true)
37
19
 
38
- // If the parent's epoch does not match the child's view of the parent's epoch, then the parent has changed.
39
- if (child.parents[i].lastChangedEpoch !== child.parentEpochs[i]) {
20
+ if (parent.lastChangedEpoch !== child.parentEpochs[i]) {
40
21
  return true
41
22
  }
42
23
  }
@@ -45,32 +26,17 @@ export function haveParentsChanged(child: Child): boolean {
45
26
  }
46
27
 
47
28
  /**
48
- * Detaches a child signal from its parent signal, removing the parent-child relationship
49
- * in the reactive dependency graph. If the parent has no remaining children and is itself
50
- * a child, it will recursively detach from its own parents.
51
- *
52
- * This function is used internally to clean up the dependency graph when signals are no
53
- * longer needed or when dependencies change.
29
+ * Removes `child` from `parent.children`. A computed parent that loses its last child stops
30
+ * listening itself, recursively, so a signal's `children` set is empty whenever nothing downstream
31
+ * is actively listening.
54
32
  *
55
- * @param parent - The parent signal to detach from
56
- * @param child - The child signal to detach
57
- * @example
58
- * ```ts
59
- * // When a computed signal's dependencies change
60
- * const oldParent = atom('old', 1)
61
- * const child = computed('child', () => oldParent.get())
62
- * // Later, detach the child from the old parent
63
- * detach(oldParent, child)
64
- * ```
65
33
  * @internal
66
34
  */
67
35
  export function detach(parent: Signal<any>, child: Child) {
68
- // If the child is not attached to the parent, do nothing.
69
36
  if (!parent.children.remove(child)) {
70
37
  return
71
38
  }
72
39
 
73
- // If the parent has no more children, then detach the parent from its parents.
74
40
  if (parent.children.isEmpty && isChild(parent)) {
75
41
  for (let i = 0, n = parent.parents.length; i < n; i++) {
76
42
  detach(parent.parents[i], parent)
@@ -79,32 +45,16 @@ export function detach(parent: Signal<any>, child: Child) {
79
45
  }
80
46
 
81
47
  /**
82
- * Attaches a child signal to its parent signal, establishing a parent-child relationship
83
- * in the reactive dependency graph. If the parent is itself a child, it will recursively
84
- * attach to its own parents to maintain the dependency chain.
48
+ * Adds `child` to `parent.children`. A computed parent that gains its first child starts
49
+ * listening itself, recursively, so that changes to any ancestor are traversed down to the child.
85
50
  *
86
- * This function is used internally when dependencies are captured during computed signal
87
- * evaluation or effect execution.
88
- *
89
- * @param parent - The parent signal to attach to
90
- * @param child - The child signal to attach
91
- * @example
92
- * ```ts
93
- * // When a computed signal captures a new dependency
94
- * const parentAtom = atom('parent', 1)
95
- * const child = computed('child', () => parentAtom.get())
96
- * // Internally, attach is called to establish the dependency
97
- * attach(parentAtom, child)
98
- * ```
99
51
  * @internal
100
52
  */
101
53
  export function attach(parent: Signal<any>, child: Child) {
102
- // If the child is already attached to the parent, do nothing.
103
54
  if (!parent.children.add(child)) {
104
55
  return
105
56
  }
106
57
 
107
- // If the parent itself is a child, add the parent to the parent's parents.
108
58
  if (isChild(parent)) {
109
59
  for (let i = 0, n = parent.parents.length; i < n; i++) {
110
60
  attach(parent.parents[i], parent)
@@ -113,25 +63,10 @@ export function attach(parent: Signal<any>, child: Child) {
113
63
  }
114
64
 
115
65
  /**
116
- * Checks if two values are equal using the equality semantics of @tldraw/state.
117
- *
118
- * This function performs equality checks in the following order:
119
- * 1. Reference equality (`===`)
120
- * 2. `Object.is()` equality (handles NaN and -0/+0 cases)
121
- * 3. Custom `.equals()` method when the left-hand value provides one
122
- *
123
- * This is used internally to determine if a signal's value has actually changed
124
- * when setting new values, preventing unnecessary updates and re-computations.
66
+ * The default equality used for change detection: `===`, then `Object.is` (so `NaN` equals
67
+ * `NaN`; `0` and `-0` are already equal by `===`), then the old value's own `.equals(b)` method
68
+ * if it has one. Only the old value's `equals` is consulted.
125
69
  *
126
- * @param a - The first value to compare
127
- * @param b - The second value to compare
128
- * @returns `true` if the values are considered equal, `false` otherwise
129
- * @example
130
- * ```ts
131
- * equals(1, 1) // true
132
- * equals(NaN, NaN) // true (unlike === which returns false)
133
- * equals({ equals: (other: any) => other.id === 1 }, { id: 1 }) // Uses custom equals method
134
- * ```
135
70
  * @internal
136
71
  */
137
72
  export function equals(a: any, b: any): boolean {
@@ -140,32 +75,6 @@ export function equals(a: any, b: any): boolean {
140
75
  return shallowEquals
141
76
  }
142
77
 
143
- /**
144
- * A TypeScript utility function for exhaustiveness checking in switch statements and
145
- * conditional branches. This function should never be called at runtime—it exists
146
- * purely for compile-time type checking and is `undefined` in emitted JavaScript.
147
- *
148
- * @param x - A value that should be of type `never`
149
- * @throws Always at runtime because the identifier is undefined
150
- * @example
151
- * ```ts
152
- * type Color = 'red' | 'blue'
153
- *
154
- * function handleColor(color: Color) {
155
- * switch (color) {
156
- * case 'red':
157
- * return 'Stop'
158
- * case 'blue':
159
- * return 'Go'
160
- * default:
161
- * return assertNever(color) // TypeScript error if not all cases handled
162
- * }
163
- * }
164
- * ```
165
- * @public
166
- */
167
- export declare function assertNever(x: never): never
168
-
169
78
  /**
170
79
  * Creates or retrieves a singleton instance using a global symbol registry.
171
80
  * This ensures that the same instance is shared across all code that uses
@@ -199,37 +108,3 @@ export function singleton<T>(key: string, init: () => T): T {
199
108
  * @public
200
109
  */
201
110
  export const EMPTY_ARRAY: [] = singleton('empty_array', () => Object.freeze([]) as any)
202
-
203
- /**
204
- * Checks if a signal has any active reactors (effects or computed signals) that are
205
- * currently listening to it. This determines whether changes to the signal will
206
- * cause any side effects or recomputations to occur.
207
- *
208
- * A signal is considered to have active reactors if any of its child dependencies
209
- * are actively listening for changes.
210
- *
211
- * @param signal - The signal to check for active reactors
212
- * @returns `true` if the signal has active reactors, `false` otherwise
213
- * @example
214
- * ```ts
215
- * const count = atom('count', 0)
216
- *
217
- * console.log(hasReactors(count)) // false - no effects listening
218
- *
219
- * const stop = react('logger', () => console.log(count.get()))
220
- * console.log(hasReactors(count)) // true - effect is listening
221
- *
222
- * stop()
223
- * console.log(hasReactors(count)) // false - effect stopped
224
- * ```
225
- * @public
226
- */
227
- export function hasReactors(signal: Signal<any>) {
228
- for (const child of signal.children) {
229
- if (child.isActivelyListening) {
230
- return true
231
- }
232
- }
233
-
234
- return false
235
- }
@@ -75,12 +75,19 @@ export function localStorageAtom<Value, Diff = unknown>(
75
75
  }
76
76
  }
77
77
 
78
- window.addEventListener('storage', handleStorageEvent)
78
+ // The storage helpers above tolerate environments without localStorage (Node, SSR); do the
79
+ // same here rather than throwing on `window`.
80
+ const canListen = typeof window !== 'undefined'
81
+ if (canListen) {
82
+ window.addEventListener('storage', handleStorageEvent)
83
+ }
79
84
 
80
85
  // Combined cleanup function
81
86
  const cleanup = () => {
82
87
  reactCleanup()
83
- window.removeEventListener('storage', handleStorageEvent)
88
+ if (canListen) {
89
+ window.removeEventListener('storage', handleStorageEvent)
90
+ }
84
91
  }
85
92
 
86
93
  return [outAtom, cleanup]
@@ -17,21 +17,11 @@ class Transaction {
17
17
 
18
18
  initialAtomValues = new Map<_Atom, any>()
19
19
 
20
- /**
21
- * Get whether this transaction is a root (no parents).
22
- *
23
- * @public
24
- */
25
20
  // eslint-disable-next-line tldraw/no-setter-getter
26
21
  get isRoot() {
27
22
  return this.parent === null
28
23
  }
29
24
 
30
- /**
31
- * Commit the transaction's changes.
32
- *
33
- * @public
34
- */
35
25
  commit() {
36
26
  if (inst.globalIsReacting) {
37
27
  // if we're committing during a reaction we actually need to
@@ -44,9 +34,17 @@ class Transaction {
44
34
  flushChanges(this.initialAtomValues.keys())
45
35
  } else {
46
36
  // For transactions with parents, add the transaction's initial values to the parent's.
37
+ // A parent that has recorded nothing yet adopts the map outright: this transaction is
38
+ // finished with it, and the common nested case is a single inner transaction doing all
39
+ // the writes.
40
+ const parentValues = this.parent!.initialAtomValues
41
+ if (parentValues.size === 0) {
42
+ this.parent!.initialAtomValues = this.initialAtomValues
43
+ return
44
+ }
47
45
  this.initialAtomValues.forEach((value, atom) => {
48
- if (!this.parent!.initialAtomValues.has(atom)) {
49
- this.parent!.initialAtomValues.set(atom, value)
46
+ if (!parentValues.has(atom)) {
47
+ parentValues.set(atom, value)
50
48
  }
51
49
  })
52
50
  }
@@ -60,13 +58,11 @@ class Transaction {
60
58
  abort() {
61
59
  inst.globalEpoch++
62
60
 
63
- // Reset each of the transaction's atoms to its initial value.
64
61
  this.initialAtomValues.forEach((value, atom) => {
65
62
  atom.set(value)
66
63
  atom.historyBuffer?.clear()
67
64
  })
68
65
 
69
- // Commit the changes.
70
66
  this.commit()
71
67
  }
72
68
  }
@@ -115,7 +111,9 @@ export function getIsReacting() {
115
111
  return inst.globalIsReacting
116
112
  }
117
113
 
118
- // Reusable state for traverse to avoid closure allocation
114
+ // The set `traverseChild` collects reactors into. Module-level rather than a closure so that a
115
+ // flush over thousands of atoms doesn't allocate a visitor per atom; traversal never runs user
116
+ // code, so nothing can re-enter and swap it mid-walk.
119
117
  let traverseReactors: Set<Reactor>
120
118
 
121
119
  function traverseChild(child: Child) {
@@ -132,11 +130,6 @@ function traverseChild(child: Child) {
132
130
  }
133
131
  }
134
132
 
135
- function traverse(reactors: Set<Reactor>, child: Child) {
136
- traverseReactors = reactors
137
- traverseChild(child)
138
- }
139
-
140
133
  /**
141
134
  * Collect all of the reactors that need to run for an atom and run them.
142
135
  *
@@ -154,11 +147,10 @@ function flushChanges(atoms: Iterable<_Atom>) {
154
147
  inst.globalIsReacting = true
155
148
  inst.reactionEpoch = inst.globalEpoch
156
149
 
157
- // Collect all of the visited reactors.
158
150
  const reactors = new Set<Reactor>()
159
-
151
+ traverseReactors = reactors
160
152
  for (const atom of atoms) {
161
- atom.children.visit((child) => traverse(reactors, child))
153
+ atom.children.visit(traverseChild)
162
154
  }
163
155
 
164
156
  // Run each reactor.
@@ -185,14 +177,7 @@ function flushChanges(atoms: Iterable<_Atom>) {
185
177
  }
186
178
  }
187
179
 
188
- /**
189
- * Handle a change to an atom.
190
- *
191
- * @param atom The atom that changed.
192
- * @param previousValue The atom's previous value.
193
- *
194
- * @internal
195
- */
180
+ /** @internal */
196
181
  export function atomDidChange(atom: _Atom, previousValue: any) {
197
182
  if (inst.currentTransaction) {
198
183
  // If we are in a transaction, then all we have to do is preserve
@@ -214,16 +199,11 @@ export function atomDidChange(atom: _Atom, previousValue: any) {
214
199
  }
215
200
 
216
201
  function traverseAtomForCleanup(atom: _Atom) {
217
- const rs = (inst.cleanupReactors ??= new Set())
218
- atom.children.visit((child) => traverse(rs, child))
202
+ traverseReactors = inst.cleanupReactors ??= new Set()
203
+ atom.children.visit(traverseChild)
219
204
  }
220
205
 
221
- /**
222
- * Advances the global epoch counter by one.
223
- * This is used internally to track when changes occur across the reactive system.
224
- *
225
- * @internal
226
- */
206
+ /** @internal */
227
207
  export function advanceGlobalEpoch() {
228
208
  inst.globalEpoch++
229
209
  }
@@ -251,7 +231,7 @@ export function advanceGlobalEpoch() {
251
231
  * // Logs "Hello, Jane Smith!"
252
232
  * ```
253
233
  *
254
- * If the function throws, the transaction is aborted and any signals that were updated during the transaction revert to their state before the transaction began.
234
+ * If the function throws, the transaction is aborted and any signals that were updated during the transaction revert to their state before the transaction began. An aborted transaction still flushes effects: effects whose parents went through a change-and-restore round trip are checked again and, if a parent's value differs from what they last saw (an atom they read directly always will), run once more with the restored values.
255
235
  *
256
236
  * @example
257
237
  * ```ts
@@ -269,8 +249,9 @@ export function advanceGlobalEpoch() {
269
249
  * throw new Error('oops')
270
250
  * })
271
251
  *
272
- * // Does not log
273
252
  * // firstName.get() === 'John'
253
+ * // Logs "Hello, John Doe!" again: effects whose parents were changed and restored still run,
254
+ * // and observe the restored values.
274
255
  * ```
275
256
  *
276
257
  * A `rollback` callback is passed into the function.
@@ -293,9 +274,9 @@ export function advanceGlobalEpoch() {
293
274
  * rollback()
294
275
  * })
295
276
  *
296
- * // Does not log
297
277
  * // firstName.get() === 'John'
298
278
  * // lastName.get() === 'Doe'
279
+ * // Logs "Hello, John Doe!" again, as above.
299
280
  * ```
300
281
  *
301
282
  * @param fn - The function to run in a transaction, called with a function to roll back the change.
@@ -305,7 +286,6 @@ export function advanceGlobalEpoch() {
305
286
  export function transaction<T>(fn: (rollback: () => void) => T) {
306
287
  const txn = new Transaction(inst.currentTransaction, true)
307
288
 
308
- // Set the current transaction to the transaction
309
289
  inst.currentTransaction = txn
310
290
 
311
291
  try {
@@ -313,10 +293,8 @@ export function transaction<T>(fn: (rollback: () => void) => T) {
313
293
  let rollback = false
314
294
 
315
295
  try {
316
- // Run the function.
317
296
  result = fn(() => (rollback = true))
318
297
  } catch (e) {
319
- // Abort the transaction if the function throws.
320
298
  txn.abort()
321
299
  throw e
322
300
  }
@@ -326,7 +304,6 @@ export function transaction<T>(fn: (rollback: () => void) => T) {
326
304
  }
327
305
 
328
306
  if (rollback) {
329
- // If the rollback was triggered, abort the transaction.
330
307
  txn.abort()
331
308
  } else {
332
309
  txn.commit()
@@ -334,7 +311,6 @@ export function transaction<T>(fn: (rollback: () => void) => T) {
334
311
 
335
312
  return result
336
313
  } finally {
337
- // Set the current transaction to the transaction's parent.
338
314
  inst.currentTransaction = txn.parent
339
315
  }
340
316
  }
package/src/lib/types.ts CHANGED
@@ -9,12 +9,12 @@ import { ArraySet } from './ArraySet'
9
9
  *
10
10
  * @example
11
11
  * ```ts
12
- * import { atom, getGlobalEpoch, RESET_VALUE } from '@tldraw/state'
12
+ * import { atom, RESET_VALUE } from '@tldraw/state'
13
13
  *
14
- * const count = atom('count', 0, { historyLength: 3 })
15
- * const oldEpoch = getGlobalEpoch()
14
+ * const count = atom('count', 0, { historyLength: 3, computeDiff: (prev, next) => next - prev })
15
+ * const oldEpoch = count.lastChangedEpoch
16
16
  *
17
- * // Make many changes that exceed history length
17
+ * // Make more changes than the history length can hold
18
18
  * count.set(1)
19
19
  * count.set(2)
20
20
  * count.set(3)
@@ -183,8 +183,8 @@ export interface Child {
183
183
  * A function type that computes the difference between two values of a signal.
184
184
  *
185
185
  * This function is used to generate incremental diffs that can be applied to
186
- * reconstruct state changes over time. It's particularly useful for features
187
- * like undo/redo, synchronization, and change tracking.
186
+ * reconstruct state changes over time, so that downstream computeds and effects can
187
+ * update incrementally instead of recomputing from scratch.
188
188
  *
189
189
  * The function should analyze the previous and current values and return a
190
190
  * diff object that represents the change. If the diff cannot be computed
@@ -193,7 +193,7 @@ export interface Child {
193
193
  *
194
194
  * @param previousValue - The previous value of the signal
195
195
  * @param currentValue - The current value of the signal
196
- * @param lastComputedEpoch - The epoch when the previous value was set
196
+ * @param lastComputedEpoch - For an atom, the epoch when the previous value was set. For a computed, the epoch at which it was last checked (the same value its compute function receives), so that `other.getDiffSince(lastComputedEpoch)` yields exactly the changes not yet accounted for.
197
197
  * @param currentEpoch - The epoch when the current value was set
198
198
  * @returns A diff object representing the change, or the unique symbol RESET_VALUE if no diff can be computed
199
199
  *
@@ -1,10 +1,4 @@
1
- /**
2
- * Flag to track whether the computed getter deprecation warning has already been shown.
3
- * Prevents the same warning from being logged multiple times during application runtime.
4
- *
5
- * @internal
6
- */
7
- let didWarnComputedGetter = false
1
+ import { warnOnce } from '@tldraw/utils'
8
2
 
9
3
  /**
10
4
  * Logs a deprecation warning for the deprecated `@computed` getter decorator syntax.
@@ -37,9 +31,7 @@ let didWarnComputedGetter = false
37
31
  * @internal
38
32
  */
39
33
  export function logComputedGetterWarning() {
40
- if (didWarnComputedGetter) return
41
- didWarnComputedGetter = true
42
- console.warn(
34
+ warnOnce(
43
35
  `Using \`@computed\` as a decorator for getters is deprecated and will be removed in the near future. Please refactor to use \`@computed\` as a decorator for methods.
44
36
 
45
37
  // Before