@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.
- package/dist-cjs/index.d.ts +358 -43
- package/dist-cjs/index.js +3 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/ArraySet.js +90 -6
- package/dist-cjs/lib/ArraySet.js.map +2 -2
- package/dist-cjs/lib/Atom.js +70 -0
- package/dist-cjs/lib/Atom.js.map +2 -2
- package/dist-cjs/lib/Computed.js +1 -1
- package/dist-cjs/lib/Computed.js.map +2 -2
- package/dist-cjs/lib/HistoryBuffer.js +57 -9
- package/dist-cjs/lib/HistoryBuffer.js.map +2 -2
- package/dist-cjs/lib/capture.js.map +2 -2
- package/dist-cjs/lib/constants.js.map +2 -2
- package/dist-cjs/lib/helpers.js.map +2 -2
- package/dist-cjs/lib/isSignal.js.map +2 -2
- package/dist-cjs/lib/localStorageAtom.js +60 -0
- package/dist-cjs/lib/localStorageAtom.js.map +7 -0
- package/dist-cjs/lib/transactions.js.map +2 -2
- package/dist-cjs/lib/types.js.map +2 -2
- package/dist-cjs/lib/warnings.js.map +2 -2
- package/dist-esm/index.d.mts +358 -43
- package/dist-esm/index.mjs +3 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/ArraySet.mjs +90 -6
- package/dist-esm/lib/ArraySet.mjs.map +2 -2
- package/dist-esm/lib/Atom.mjs +70 -0
- package/dist-esm/lib/Atom.mjs.map +2 -2
- package/dist-esm/lib/Computed.mjs +1 -1
- package/dist-esm/lib/Computed.mjs.map +2 -2
- package/dist-esm/lib/HistoryBuffer.mjs +57 -9
- package/dist-esm/lib/HistoryBuffer.mjs.map +2 -2
- package/dist-esm/lib/capture.mjs.map +2 -2
- package/dist-esm/lib/constants.mjs.map +2 -2
- package/dist-esm/lib/helpers.mjs.map +2 -2
- package/dist-esm/lib/isSignal.mjs.map +2 -2
- package/dist-esm/lib/localStorageAtom.mjs +40 -0
- package/dist-esm/lib/localStorageAtom.mjs.map +7 -0
- package/dist-esm/lib/transactions.mjs.map +2 -2
- package/dist-esm/lib/types.mjs.map +2 -2
- package/dist-esm/lib/warnings.mjs.map +2 -2
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/lib/ArraySet.ts +101 -8
- package/src/lib/Atom.ts +92 -0
- package/src/lib/Computed.ts +169 -19
- package/src/lib/HistoryBuffer.ts +76 -10
- package/src/lib/__tests__/helpers.test.ts +168 -0
- package/src/lib/__tests__/localStorageAtom.test.ts +183 -0
- package/src/lib/capture.ts +56 -1
- package/src/lib/constants.ts +25 -1
- package/src/lib/helpers.ts +137 -13
- package/src/lib/isSignal.ts +24 -0
- package/src/lib/localStorageAtom.ts +87 -0
- package/src/lib/transactions.ts +79 -10
- package/src/lib/types.ts +173 -19
- 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
|
+
})
|
package/src/lib/capture.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/lib/constants.ts
CHANGED
|
@@ -1,2 +1,26 @@
|
|
|
1
|
-
|
|
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
|
package/src/lib/helpers.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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>) {
|
package/src/lib/isSignal.ts
CHANGED
|
@@ -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> {
|