@tldraw/state 4.0.2 → 4.1.0-canary.0259516ffb8c

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 (56) hide show
  1. package/dist-cjs/index.d.ts +358 -43
  2. package/dist-cjs/index.js +3 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/ArraySet.js +90 -6
  5. package/dist-cjs/lib/ArraySet.js.map +2 -2
  6. package/dist-cjs/lib/Atom.js +70 -0
  7. package/dist-cjs/lib/Atom.js.map +2 -2
  8. package/dist-cjs/lib/Computed.js +1 -1
  9. package/dist-cjs/lib/Computed.js.map +2 -2
  10. package/dist-cjs/lib/HistoryBuffer.js +57 -9
  11. package/dist-cjs/lib/HistoryBuffer.js.map +2 -2
  12. package/dist-cjs/lib/capture.js.map +2 -2
  13. package/dist-cjs/lib/constants.js.map +2 -2
  14. package/dist-cjs/lib/helpers.js.map +2 -2
  15. package/dist-cjs/lib/isSignal.js.map +2 -2
  16. package/dist-cjs/lib/localStorageAtom.js +60 -0
  17. package/dist-cjs/lib/localStorageAtom.js.map +7 -0
  18. package/dist-cjs/lib/transactions.js.map +2 -2
  19. package/dist-cjs/lib/types.js.map +2 -2
  20. package/dist-cjs/lib/warnings.js.map +2 -2
  21. package/dist-esm/index.d.mts +358 -43
  22. package/dist-esm/index.mjs +3 -1
  23. package/dist-esm/index.mjs.map +2 -2
  24. package/dist-esm/lib/ArraySet.mjs +90 -6
  25. package/dist-esm/lib/ArraySet.mjs.map +2 -2
  26. package/dist-esm/lib/Atom.mjs +70 -0
  27. package/dist-esm/lib/Atom.mjs.map +2 -2
  28. package/dist-esm/lib/Computed.mjs +1 -1
  29. package/dist-esm/lib/Computed.mjs.map +2 -2
  30. package/dist-esm/lib/HistoryBuffer.mjs +57 -9
  31. package/dist-esm/lib/HistoryBuffer.mjs.map +2 -2
  32. package/dist-esm/lib/capture.mjs.map +2 -2
  33. package/dist-esm/lib/constants.mjs.map +2 -2
  34. package/dist-esm/lib/helpers.mjs.map +2 -2
  35. package/dist-esm/lib/isSignal.mjs.map +2 -2
  36. package/dist-esm/lib/localStorageAtom.mjs +40 -0
  37. package/dist-esm/lib/localStorageAtom.mjs.map +7 -0
  38. package/dist-esm/lib/transactions.mjs.map +2 -2
  39. package/dist-esm/lib/types.mjs.map +2 -2
  40. package/dist-esm/lib/warnings.mjs.map +2 -2
  41. package/package.json +2 -2
  42. package/src/index.ts +1 -0
  43. package/src/lib/ArraySet.ts +101 -8
  44. package/src/lib/Atom.ts +92 -0
  45. package/src/lib/Computed.ts +169 -19
  46. package/src/lib/HistoryBuffer.ts +76 -10
  47. package/src/lib/__tests__/helpers.test.ts +168 -0
  48. package/src/lib/__tests__/localStorageAtom.test.ts +183 -0
  49. package/src/lib/capture.ts +56 -1
  50. package/src/lib/constants.ts +25 -1
  51. package/src/lib/helpers.ts +137 -13
  52. package/src/lib/isSignal.ts +24 -0
  53. package/src/lib/localStorageAtom.ts +87 -0
  54. package/src/lib/transactions.ts +79 -10
  55. package/src/lib/types.ts +173 -19
  56. package/src/lib/warnings.ts +36 -0
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { ArraySet } from '../ArraySet'
3
+ import { atom } from '../Atom'
4
+ import { reactor } from '../EffectScheduler'
5
+ import { attach, detach, equals, hasReactors, haveParentsChanged, singleton } from '../helpers'
6
+ import { Child } from '../types'
7
+
8
+ describe('helpers', () => {
9
+ describe('haveParentsChanged', () => {
10
+ it('returns false when no parents exist', () => {
11
+ const child: Child = {
12
+ parents: [],
13
+ parentEpochs: [],
14
+ parentSet: new ArraySet(),
15
+ name: 'test-child',
16
+ lastTraversedEpoch: 0,
17
+ isActivelyListening: true,
18
+ __debug_ancestor_epochs__: null,
19
+ }
20
+
21
+ expect(haveParentsChanged(child)).toBe(false)
22
+ })
23
+
24
+ it('returns true when parent epoch has changed', () => {
25
+ const parentAtom = atom('parent', 1)
26
+ const oldEpoch = parentAtom.lastChangedEpoch
27
+
28
+ const child: Child = {
29
+ parents: [parentAtom],
30
+ parentEpochs: [oldEpoch],
31
+ parentSet: new ArraySet(),
32
+ name: 'test-child',
33
+ lastTraversedEpoch: 0,
34
+ isActivelyListening: true,
35
+ __debug_ancestor_epochs__: null,
36
+ }
37
+
38
+ // Change the parent, which should update its epoch
39
+ parentAtom.set(2)
40
+
41
+ expect(haveParentsChanged(child)).toBe(true)
42
+ })
43
+
44
+ it('returns true when any parent has changed among multiple parents', () => {
45
+ const parent1 = atom('parent1', 1)
46
+ const parent2 = atom('parent2', 2)
47
+ const oldEpoch1 = parent1.lastChangedEpoch
48
+ const oldEpoch2 = parent2.lastChangedEpoch
49
+
50
+ const child: Child = {
51
+ parents: [parent1, parent2],
52
+ parentEpochs: [oldEpoch1, oldEpoch2],
53
+ parentSet: new ArraySet(),
54
+ name: 'test-child',
55
+ lastTraversedEpoch: 0,
56
+ isActivelyListening: true,
57
+ __debug_ancestor_epochs__: null,
58
+ }
59
+
60
+ // Change only the second parent
61
+ parent2.set(3)
62
+
63
+ expect(haveParentsChanged(child)).toBe(true)
64
+ })
65
+ })
66
+
67
+ describe('detach', () => {
68
+ it('removes child from parent children when attached', () => {
69
+ const parent = atom('parent', 1)
70
+ const child: Child = {
71
+ parents: [],
72
+ parentEpochs: [],
73
+ parentSet: new ArraySet(),
74
+ name: 'test-child',
75
+ lastTraversedEpoch: 0,
76
+ isActivelyListening: true,
77
+ __debug_ancestor_epochs__: null,
78
+ }
79
+
80
+ parent.children.add(child)
81
+ expect(parent.children.size()).toBe(1)
82
+
83
+ detach(parent, child)
84
+
85
+ expect(parent.children.size()).toBe(0)
86
+ })
87
+ })
88
+
89
+ describe('attach', () => {
90
+ it('adds child to parent children when not already attached', () => {
91
+ const parent = atom('parent', 1)
92
+ const child: Child = {
93
+ parents: [],
94
+ parentEpochs: [],
95
+ parentSet: new ArraySet(),
96
+ name: 'test-child',
97
+ lastTraversedEpoch: 0,
98
+ isActivelyListening: true,
99
+ __debug_ancestor_epochs__: null,
100
+ }
101
+
102
+ expect(parent.children.size()).toBe(0)
103
+
104
+ attach(parent, child)
105
+
106
+ expect(parent.children.size()).toBe(1)
107
+ expect(Array.from(parent.children)).toContain(child)
108
+ })
109
+ })
110
+
111
+ describe('equals', () => {
112
+ it('returns true for identical references and Object.is cases', () => {
113
+ const obj = { a: 1 }
114
+ expect(equals(obj, obj)).toBe(true)
115
+ expect(equals(1, 1)).toBe(true)
116
+ expect(equals(NaN, NaN)).toBe(true)
117
+ })
118
+
119
+ it('returns false for different values', () => {
120
+ expect(equals(1, 2)).toBe(false)
121
+ expect(equals({ id: 1 }, { id: 1 })).toBe(false)
122
+ })
123
+
124
+ it('uses custom equals method when available', () => {
125
+ const obj1 = {
126
+ id: 1,
127
+ equals: (other: any) => other && other.id === 1,
128
+ }
129
+ const obj2 = { id: 1, name: 'test' }
130
+
131
+ expect(equals(obj1, obj2)).toBe(true)
132
+ })
133
+ })
134
+
135
+ describe('singleton', () => {
136
+ it('returns same instance on subsequent calls with same key', () => {
137
+ const init = vi.fn(() => ({ value: 42 }))
138
+
139
+ const instance1 = singleton('test-singleton', init)
140
+ const instance2 = singleton('test-singleton', init)
141
+
142
+ expect(init).toHaveBeenCalledTimes(1)
143
+ expect(instance1).toBe(instance2)
144
+ })
145
+ })
146
+
147
+ describe('hasReactors', () => {
148
+ it('returns false when signal has no actively listening children', () => {
149
+ const signal = atom('test', 1)
150
+ expect(hasReactors(signal)).toBe(false)
151
+ })
152
+
153
+ it('integrates correctly with real reactive signals', () => {
154
+ const baseAtom = atom('base', 1)
155
+ expect(hasReactors(baseAtom)).toBe(false)
156
+
157
+ const reactorInstance = reactor('test-reactor', () => {
158
+ baseAtom.get()
159
+ })
160
+
161
+ reactorInstance.start()
162
+ expect(hasReactors(baseAtom)).toBe(true)
163
+
164
+ reactorInstance.stop()
165
+ expect(hasReactors(baseAtom)).toBe(false)
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,183 @@
1
+ import { localStorageAtom } from '../localStorageAtom'
2
+ import { getGlobalEpoch } from '../transactions'
3
+
4
+ // Mock localStorage
5
+ const mockLocalStorage = (() => {
6
+ let store: Record<string, string> = {}
7
+
8
+ return {
9
+ getItem: vi.fn((key: string) => store[key] || null),
10
+ setItem: vi.fn((key: string, value: string) => {
11
+ store[key] = value
12
+ }),
13
+ removeItem: vi.fn((key: string) => {
14
+ delete store[key]
15
+ }),
16
+ clear: vi.fn(() => {
17
+ store = {}
18
+ }),
19
+ get length() {
20
+ return Object.keys(store).length
21
+ },
22
+ key: vi.fn((index: number) => Object.keys(store)[index] || null),
23
+ // Internal helper to reset store
24
+ _reset: () => {
25
+ store = {}
26
+ },
27
+ }
28
+ })()
29
+
30
+ // Mock the @tldraw/utils functions
31
+ vi.mock('@tldraw/utils', () => ({
32
+ getFromLocalStorage: (key: string) => mockLocalStorage.getItem(key),
33
+ setInLocalStorage: (key: string, value: string) => mockLocalStorage.setItem(key, value),
34
+ deleteFromLocalStorage: (key: string) => mockLocalStorage.removeItem(key),
35
+ }))
36
+
37
+ describe('localStorageAtom', () => {
38
+ beforeEach(() => {
39
+ mockLocalStorage._reset()
40
+ vi.clearAllMocks()
41
+ })
42
+
43
+ describe('initialization', () => {
44
+ it('should create atom with initial value when localStorage is empty', () => {
45
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
46
+
47
+ expect(atom.get()).toBe('initial-value')
48
+ cleanup()
49
+ })
50
+
51
+ it('should restore value from localStorage when it exists', () => {
52
+ mockLocalStorage.setItem('test-key', JSON.stringify('stored-value'))
53
+
54
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
55
+
56
+ expect(atom.get()).toBe('stored-value')
57
+ cleanup()
58
+ })
59
+ })
60
+
61
+ describe('corrupted localStorage handling', () => {
62
+ it('should use initial value and delete corrupted localStorage entry', () => {
63
+ mockLocalStorage.setItem('test-key', 'invalid-json')
64
+
65
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
66
+
67
+ expect(atom.get()).toBe('initial-value')
68
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('test-key')
69
+ cleanup()
70
+ })
71
+
72
+ it('should handle empty string in localStorage', () => {
73
+ mockLocalStorage.setItem('test-key', '')
74
+
75
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial-value')
76
+
77
+ // Empty string should be falsy, so initial value is used
78
+ expect(atom.get()).toBe('initial-value')
79
+ cleanup()
80
+ })
81
+ })
82
+
83
+ describe('localStorage synchronization', () => {
84
+ it('should save to localStorage when atom value changes', () => {
85
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
86
+
87
+ atom.set('new-value')
88
+
89
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('test-key', JSON.stringify('new-value'))
90
+ cleanup()
91
+ })
92
+
93
+ it('should update localStorage on multiple changes', () => {
94
+ const [atom, cleanup] = localStorageAtom('counter', 0)
95
+
96
+ // Clear initial call from atom creation
97
+ vi.clearAllMocks()
98
+
99
+ atom.set(1)
100
+ atom.set(2)
101
+ atom.set(3)
102
+
103
+ expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(3)
104
+ expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(1, 'counter', JSON.stringify(1))
105
+ expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(2, 'counter', JSON.stringify(2))
106
+ expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(3, 'counter', JSON.stringify(3))
107
+ cleanup()
108
+ })
109
+ })
110
+
111
+ describe('cleanup functionality', () => {
112
+ it('should stop syncing to localStorage after cleanup', () => {
113
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
114
+
115
+ // Change value before cleanup - should sync
116
+ atom.set('before-cleanup')
117
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
118
+ 'test-key',
119
+ JSON.stringify('before-cleanup')
120
+ )
121
+
122
+ // Clear mocks and cleanup
123
+ vi.clearAllMocks()
124
+ cleanup()
125
+
126
+ // Change value after cleanup - should not sync
127
+ atom.set('after-cleanup')
128
+ expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
129
+ })
130
+
131
+ it('should allow atom to continue functioning after cleanup', () => {
132
+ const [atom, cleanup] = localStorageAtom('test-key', 'initial')
133
+
134
+ cleanup()
135
+
136
+ atom.set('new-value')
137
+ expect(atom.get()).toBe('new-value')
138
+ })
139
+ })
140
+
141
+ describe('atom options', () => {
142
+ it('should pass through atom options', () => {
143
+ const isEqual = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
144
+ const [atom, cleanup] = localStorageAtom('test-key', 'Hello', { isEqual })
145
+
146
+ atom.set('HELLO')
147
+ expect(atom.get()).toBe('Hello') // Should use custom equality
148
+ cleanup()
149
+ })
150
+
151
+ it('should work with history options', () => {
152
+ const [atom, cleanup] = localStorageAtom('test-key', 0, {
153
+ historyLength: 3,
154
+ computeDiff: (a, b) => b - a,
155
+ })
156
+
157
+ const startEpoch = getGlobalEpoch()
158
+
159
+ atom.set(5)
160
+ atom.set(10)
161
+
162
+ const diffs = atom.getDiffSince(startEpoch)
163
+ expect(diffs).toEqual([5, 5])
164
+ cleanup()
165
+ })
166
+ })
167
+
168
+ describe('multiple instances', () => {
169
+ it('should handle multiple atoms with different keys', () => {
170
+ const [atom1, cleanup1] = localStorageAtom('key1', 'value1')
171
+ const [atom2, cleanup2] = localStorageAtom('key2', 'value2')
172
+
173
+ atom1.set('updated1')
174
+ atom2.set('updated2')
175
+
176
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('key1', JSON.stringify('updated1'))
177
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('key2', JSON.stringify('updated2'))
178
+
179
+ cleanup1()
180
+ cleanup2()
181
+ })
182
+ })
183
+ })
@@ -48,6 +48,25 @@ export function unsafe__withoutCapture<T>(fn: () => T): T {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * Begins capturing parent signal dependencies for the given child signal.
53
+ *
54
+ * This function initiates a capture session where any signal accessed via `.get()`
55
+ * will be automatically registered as a dependency of the child signal. It sets up
56
+ * the capture stack frame and clears the existing parent set to prepare for fresh
57
+ * dependency tracking.
58
+ *
59
+ * @param child - The child signal (computed or effect) that will capture dependencies
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const effect = createEffect('myEffect', () => { /* ... *\/ })
64
+ * startCapturingParents(effect)
65
+ * // Now any signal.get() calls will be captured as dependencies
66
+ * ```
67
+ *
68
+ * @internal
69
+ */
51
70
  export function startCapturingParents(child: Child) {
52
71
  inst.stack = new CaptureStackFrame(inst.stack, child)
53
72
  if (child.__debug_ancestor_epochs__) {
@@ -61,6 +80,23 @@ export function startCapturingParents(child: Child) {
61
80
  child.parentSet.clear()
62
81
  }
63
82
 
83
+ /**
84
+ * Completes the parent dependency capture session and finalizes the dependency graph.
85
+ *
86
+ * This function cleans up the capture session by removing dependencies that are no
87
+ * longer needed, detaching signals that should no longer be parents, and updating
88
+ * the dependency arrays to reflect the current set of captured parents. It must be
89
+ * called after `startCapturingParents` to complete the capture cycle.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * startCapturingParents(effect)
94
+ * // ... signal.get() calls happen here ...
95
+ * stopCapturingParents() // Finalizes the dependency graph
96
+ * ```
97
+ *
98
+ * @internal
99
+ */
64
100
  export function stopCapturingParents() {
65
101
  const frame = inst.stack!
66
102
  inst.stack = frame.below
@@ -91,7 +127,26 @@ export function stopCapturingParents() {
91
127
  }
92
128
  }
93
129
 
94
- // this must be called after the parent is up to date
130
+ /**
131
+ * Conditionally captures a signal as a parent dependency during an active capture session.
132
+ *
133
+ * This function is called whenever a signal's `.get()` method is invoked during a
134
+ * capture session. It checks if the signal should be added as a dependency and manages
135
+ * the parent-child relationship in the reactive graph. The function handles deduplication,
136
+ * attachment/detachment, and tracks changes in dependency order.
137
+ *
138
+ * Note: This must be called after the parent signal is up to date.
139
+ *
140
+ * @param p - The signal that might be captured as a parent dependency
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * // This is called internally when you do:
145
+ * const value = someAtom.get() // maybeCaptureParent(someAtom) is called
146
+ * ```
147
+ *
148
+ * @internal
149
+ */
95
150
  export function maybeCaptureParent(p: Signal<any, any>) {
96
151
  if (inst.stack) {
97
152
  const wasCapturedAlready = inst.stack.child.parentSet.has(p)
@@ -1,2 +1,26 @@
1
- // Derivations start on GLOBAL_START_EPOCH so they are dirty before having been computed
1
+ /**
2
+ * The initial epoch value used to mark derivations (computed signals and effects) as dirty before their first computation.
3
+ *
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
25
+ */
2
26
  export const GLOBAL_START_EPOCH = -1
@@ -5,16 +5,30 @@ import { Child, Signal } from './types'
5
5
  *
6
6
  * @param x The value to check.
7
7
  * @returns True if the value is a child, false otherwise.
8
+ * @internal
8
9
  */
9
10
  function isChild(x: any): x is Child {
10
11
  return x && typeof x === 'object' && 'parents' in x
11
12
  }
12
13
 
13
14
  /**
14
- * Get whether a child's parents have changed.
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.
15
17
  *
16
- * @param child The child to check.
17
- * @returns True if the child's parents have changed, false otherwise.
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.
20
+ *
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
+ * @internal
18
32
  */
19
33
  export function haveParentsChanged(child: Child): boolean {
20
34
  for (let i = 0, n = child.parents.length; i < n; i++) {
@@ -31,10 +45,24 @@ export function haveParentsChanged(child: Child): boolean {
31
45
  }
32
46
 
33
47
  /**
34
- * Detach a child from a parent.
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.
35
54
  *
36
- * @param parent The parent to detach from.
37
- * @param child The child to detach.
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
+ * @internal
38
66
  */
39
67
  export function detach(parent: Signal<any>, child: Child) {
40
68
  // If the child is not attached to the parent, do nothing.
@@ -51,10 +79,24 @@ export function detach(parent: Signal<any>, child: Child) {
51
79
  }
52
80
 
53
81
  /**
54
- * Attach a child to a parent.
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.
85
+ *
86
+ * This function is used internally when dependencies are captured during computed signal
87
+ * evaluation or effect execution.
55
88
  *
56
- * @param parent The parent to attach to.
57
- * @param child The child to attach.
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
+ * @internal
58
100
  */
59
101
  export function attach(parent: Signal<any>, child: Child) {
60
102
  // If the child is already attached to the parent, do nothing.
@@ -71,10 +113,26 @@ export function attach(parent: Signal<any>, child: Child) {
71
113
  }
72
114
 
73
115
  /**
74
- * Get whether two values are equal (insofar as @tldraw/state is concerned).
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.
75
125
  *
76
- * @param a The first value.
77
- * @param b The second value.
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
+ * @internal
78
136
  */
79
137
  export function equals(a: any, b: any): boolean {
80
138
  const shallowEquals =
@@ -82,8 +140,54 @@ export function equals(a: any, b: any): boolean {
82
140
  return shallowEquals
83
141
  }
84
142
 
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
+ */
85
167
  export declare function assertNever(x: never): never
86
168
 
169
+ /**
170
+ * Creates or retrieves a singleton instance using a global symbol registry.
171
+ * This ensures that the same instance is shared across all code that uses
172
+ * the same key, even across different module boundaries.
173
+ *
174
+ * The singleton is stored on `globalThis` using a symbol created with
175
+ * `Symbol.for()`, which ensures global uniqueness across realms.
176
+ *
177
+ * @param key - A unique string identifier for the singleton
178
+ * @param init - A function that creates the initial value if it doesn't exist
179
+ * @returns The singleton instance
180
+ * @example
181
+ * ```ts
182
+ * // Create a singleton logger
183
+ * const logger = singleton('logger', () => new Logger())
184
+ *
185
+ * // Elsewhere in the codebase, get the same logger instance
186
+ * const sameLogger = singleton('logger', () => new Logger())
187
+ * // logger === sameLogger
188
+ * ```
189
+ * @internal
190
+ */
87
191
  export function singleton<T>(key: string, init: () => T): T {
88
192
  const symbol = Symbol.for(`com.tldraw.state/${key}`)
89
193
  const global = globalThis as any
@@ -97,7 +201,27 @@ export function singleton<T>(key: string, init: () => T): T {
97
201
  export const EMPTY_ARRAY: [] = singleton('empty_array', () => Object.freeze([]) as any)
98
202
 
99
203
  /**
100
- * Does this signal have any active reactors attached to it? When it changes, will it cause anything to run?
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
+ * ```
101
225
  * @public
102
226
  */
103
227
  export function hasReactors(signal: Signal<any>) {
@@ -3,6 +3,30 @@ import { _Computed } from './Computed'
3
3
  import { Signal } from './types'
4
4
 
5
5
  /**
6
+ * Type guard function that determines whether a value is a signal (either an Atom or Computed).
7
+ *
8
+ * This utility function is helpful when working with mixed data types and you need to
9
+ * differentiate between regular values and reactive signal containers. It returns `true`
10
+ * if the provided value is either an atomic signal created with `atom()` or a computed
11
+ * signal created with `computed()`.
12
+ *
13
+ * @param value - The value to check, can be of any type
14
+ * @returns `true` if the value is a Signal (Atom or Computed), `false` otherwise
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { atom, computed, isSignal } from '@tldraw/state'
19
+ *
20
+ * const count = atom('count', 5)
21
+ * const doubled = computed('doubled', () => count.get() * 2)
22
+ * const regularValue = 'hello'
23
+ *
24
+ * console.log(isSignal(count)) // true
25
+ * console.log(isSignal(doubled)) // true
26
+ * console.log(isSignal(regularValue)) // false
27
+ * console.log(isSignal(null)) // false
28
+ * ```
29
+ *
6
30
  * @public
7
31
  */
8
32
  export function isSignal(value: any): value is Signal<any> {