@tldraw/state-react 4.1.0-next.1b89b40eff1c → 4.1.0-next.9f145d10c7d0
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 +241 -35
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/track.js.map +2 -2
- package/dist-cjs/lib/useComputed.js.map +2 -2
- package/dist-cjs/lib/useQuickReactor.js.map +2 -2
- package/dist-cjs/lib/useReactor.js.map +2 -2
- package/dist-cjs/lib/useStateTracking.js +1 -1
- package/dist-cjs/lib/useStateTracking.js.map +2 -2
- package/dist-cjs/lib/useValue.js.map +2 -2
- package/dist-esm/index.d.mts +241 -35
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/track.mjs.map +2 -2
- package/dist-esm/lib/useComputed.mjs.map +2 -2
- package/dist-esm/lib/useQuickReactor.mjs.map +2 -2
- package/dist-esm/lib/useReactor.mjs.map +2 -2
- package/dist-esm/lib/useStateTracking.mjs.map +2 -2
- package/dist-esm/lib/useValue.mjs.map +2 -2
- package/package.json +3 -3
- package/src/lib/track.test.tsx +1 -1
- package/src/lib/track.ts +65 -2
- package/src/lib/useComputed.ts +62 -11
- package/src/lib/useQuickReactor.test.tsx +405 -0
- package/src/lib/useQuickReactor.ts +40 -1
- package/src/lib/useReactor.test.tsx +128 -0
- package/src/lib/useReactor.ts +72 -1
- package/src/lib/useStateTracking.ts +5 -0
- package/src/lib/useValue.ts +55 -22
package/src/lib/useComputed.ts
CHANGED
|
@@ -2,23 +2,65 @@
|
|
|
2
2
|
import { Computed, ComputedOptions, computed } from '@tldraw/state'
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new computed signal that automatically tracks its dependencies and recalculates when they change.
|
|
7
|
+
* This overload is for basic computed values without custom options.
|
|
8
|
+
*
|
|
9
|
+
* @param name - A descriptive name for the computed signal, used for debugging and identification
|
|
10
|
+
* @param compute - A function that computes the value, automatically tracking any signal dependencies
|
|
11
|
+
* @param deps - React dependency array that controls when the computed signal is recreated
|
|
12
|
+
* @returns A computed signal containing the calculated value
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const firstName = atom('firstName', 'John')
|
|
17
|
+
* const lastName = atom('lastName', 'Doe')
|
|
18
|
+
*
|
|
19
|
+
* function UserProfile() {
|
|
20
|
+
* const fullName = useComputed(
|
|
21
|
+
* 'fullName',
|
|
22
|
+
* () => `${firstName.get()} ${lastName.get()}`,
|
|
23
|
+
* [firstName, lastName]
|
|
24
|
+
* )
|
|
25
|
+
*
|
|
26
|
+
* return <div>Welcome, {fullName.get()}!</div>
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
6
32
|
export function useComputed<Value>(name: string, compute: () => Value, deps: any[]): Computed<Value>
|
|
7
33
|
|
|
8
34
|
/**
|
|
9
|
-
* Creates a new computed signal
|
|
35
|
+
* Creates a new computed signal with custom options for advanced behavior like custom equality checking,
|
|
36
|
+
* diff computation, and history tracking. The computed signal will be created only once.
|
|
37
|
+
*
|
|
38
|
+
* @param name - A descriptive name for the computed signal, used for debugging and identification
|
|
39
|
+
* @param compute - A function that computes the value, automatically tracking any signal dependencies
|
|
40
|
+
* @param opts - Configuration options for the computed signal
|
|
41
|
+
* - isEqual - Custom equality function to determine if the computed value has changed
|
|
42
|
+
* - computeDiff - Function to compute diffs between old and new values for history tracking
|
|
43
|
+
* - historyLength - Maximum number of diffs to keep in history buffer for time-travel functionality
|
|
44
|
+
* @param deps - React dependency array that controls when the computed signal is recreated
|
|
45
|
+
* @returns A computed signal containing the calculated value with the specified options
|
|
10
46
|
*
|
|
11
47
|
* @example
|
|
12
48
|
* ```ts
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* lastName: Signal<string>
|
|
16
|
-
* }
|
|
49
|
+
* function ShoppingCart() {
|
|
50
|
+
* const items = useAtom('items', [])
|
|
17
51
|
*
|
|
18
|
-
*
|
|
19
|
-
* const
|
|
20
|
-
*
|
|
21
|
-
*
|
|
52
|
+
* // Computed with custom equality to avoid recalculation for equivalent arrays
|
|
53
|
+
* const sortedItems = useComputed(
|
|
54
|
+
* 'sortedItems',
|
|
55
|
+
* () => items.get().sort((a, b) => a.name.localeCompare(b.name)),
|
|
56
|
+
* {
|
|
57
|
+
* isEqual: (a, b) => a.length === b.length && a.every((item, i) => item.id === b[i].id)
|
|
58
|
+
* },
|
|
59
|
+
* [items]
|
|
60
|
+
* )
|
|
61
|
+
*
|
|
62
|
+
* return <ItemList items={sortedItems.get()} />
|
|
63
|
+
* }
|
|
22
64
|
* ```
|
|
23
65
|
*
|
|
24
66
|
* @public
|
|
@@ -29,7 +71,16 @@ export function useComputed<Value, Diff = unknown>(
|
|
|
29
71
|
opts: ComputedOptions<Value, Diff>,
|
|
30
72
|
deps: any[]
|
|
31
73
|
): Computed<Value>
|
|
32
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Implementation function that handles both overloaded signatures of useComputed.
|
|
76
|
+
* Uses the arguments object to dynamically determine which signature was called.
|
|
77
|
+
*
|
|
78
|
+
* This function creates a memoized computed signal that automatically tracks dependencies
|
|
79
|
+
* and only recreates when the dependency array changes, providing optimal performance
|
|
80
|
+
* in React components.
|
|
81
|
+
*
|
|
82
|
+
* @public
|
|
83
|
+
*/
|
|
33
84
|
export function useComputed() {
|
|
34
85
|
const name = arguments[0]
|
|
35
86
|
const compute = arguments[1]
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { act, render, RenderResult } from '@testing-library/react'
|
|
2
|
+
import { atom, Atom } from '@tldraw/state'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { vi } from 'vitest'
|
|
5
|
+
import { useAtom } from './useAtom'
|
|
6
|
+
import { useQuickReactor } from './useQuickReactor'
|
|
7
|
+
|
|
8
|
+
describe('useQuickReactor', () => {
|
|
9
|
+
let mockEffectFn: ReturnType<typeof vi.fn>
|
|
10
|
+
let _component: () => React.JSX.Element
|
|
11
|
+
let view: RenderResult
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockEffectFn = vi.fn()
|
|
15
|
+
vi.clearAllMocks()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('basic functionality', () => {
|
|
19
|
+
it('executes the effect function immediately on mount', async () => {
|
|
20
|
+
function Component() {
|
|
21
|
+
useQuickReactor('test-reactor', mockEffectFn, [])
|
|
22
|
+
return <div>test</div>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await act(() => {
|
|
26
|
+
render(<Component />)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('executes the effect function immediately when tracked signals change', async () => {
|
|
33
|
+
let theAtom: Atom<number>
|
|
34
|
+
function Component() {
|
|
35
|
+
theAtom = useAtom('counter', 0)
|
|
36
|
+
useQuickReactor(
|
|
37
|
+
'test-reactor',
|
|
38
|
+
() => {
|
|
39
|
+
mockEffectFn(theAtom.get())
|
|
40
|
+
},
|
|
41
|
+
[]
|
|
42
|
+
)
|
|
43
|
+
return <div>{theAtom.get()}</div>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await act(() => {
|
|
47
|
+
view = render(<Component />)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
|
51
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(0)
|
|
52
|
+
|
|
53
|
+
// Change the atom value - should trigger immediate effect
|
|
54
|
+
await act(() => {
|
|
55
|
+
theAtom!.set(5)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
|
59
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(5)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('works with external atoms not created via useAtom', async () => {
|
|
63
|
+
const externalAtom = atom('external', 'initial')
|
|
64
|
+
|
|
65
|
+
function Component() {
|
|
66
|
+
useQuickReactor(
|
|
67
|
+
'test-reactor',
|
|
68
|
+
() => {
|
|
69
|
+
mockEffectFn(externalAtom.get())
|
|
70
|
+
},
|
|
71
|
+
[]
|
|
72
|
+
)
|
|
73
|
+
return <div>test</div>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await act(() => {
|
|
77
|
+
render(<Component />)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(mockEffectFn).toHaveBeenCalledWith('initial')
|
|
81
|
+
|
|
82
|
+
await act(() => {
|
|
83
|
+
externalAtom.set('changed')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
|
87
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith('changed')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('dependency array behavior', () => {
|
|
92
|
+
it('recreates the reactor when dependencies change', async () => {
|
|
93
|
+
const dep = 'dep1'
|
|
94
|
+
let setDep: (newDep: string) => void
|
|
95
|
+
const reactorExecutions: string[] = []
|
|
96
|
+
|
|
97
|
+
function Component() {
|
|
98
|
+
const [currentDep, setCurrentDep] = useState(dep)
|
|
99
|
+
setDep = setCurrentDep
|
|
100
|
+
|
|
101
|
+
useQuickReactor(
|
|
102
|
+
'test-reactor',
|
|
103
|
+
() => {
|
|
104
|
+
reactorExecutions.push(`executed with ${currentDep}`)
|
|
105
|
+
mockEffectFn(currentDep)
|
|
106
|
+
},
|
|
107
|
+
[currentDep]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return <div>{currentDep}</div>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await act(() => {
|
|
114
|
+
render(<Component />)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(mockEffectFn).toHaveBeenCalledWith('dep1')
|
|
118
|
+
expect(reactorExecutions).toEqual(['executed with dep1'])
|
|
119
|
+
|
|
120
|
+
// Change dependency - should recreate reactor
|
|
121
|
+
await act(() => {
|
|
122
|
+
setDep('dep2')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
|
126
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith('dep2')
|
|
127
|
+
expect(reactorExecutions).toEqual(['executed with dep1', 'executed with dep2'])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('cleanup behavior', () => {
|
|
132
|
+
it('cleans up the effect scheduler when component unmounts', async () => {
|
|
133
|
+
let theAtom: Atom<number>
|
|
134
|
+
let isUnmounted = false
|
|
135
|
+
|
|
136
|
+
function Component() {
|
|
137
|
+
theAtom = useAtom('counter', 0)
|
|
138
|
+
useQuickReactor(
|
|
139
|
+
'test-reactor',
|
|
140
|
+
() => {
|
|
141
|
+
if (isUnmounted) {
|
|
142
|
+
mockEffectFn('should-not-execute')
|
|
143
|
+
} else {
|
|
144
|
+
mockEffectFn(theAtom.get())
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
[]
|
|
148
|
+
)
|
|
149
|
+
return <div>{theAtom.get()}</div>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await act(() => {
|
|
153
|
+
view = render(<Component />)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(mockEffectFn).toHaveBeenCalledWith(0)
|
|
157
|
+
|
|
158
|
+
// Unmount the component
|
|
159
|
+
await act(() => {
|
|
160
|
+
view.unmount()
|
|
161
|
+
isUnmounted = true
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Try to change the atom after unmount - effect should not run
|
|
165
|
+
await act(() => {
|
|
166
|
+
theAtom!.set(10)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Should still only have been called once (during mount)
|
|
170
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
|
171
|
+
expect(mockEffectFn).not.toHaveBeenCalledWith('should-not-execute')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('cleans up the previous scheduler when dependencies change', async () => {
|
|
175
|
+
const dep = 1
|
|
176
|
+
let setDep: (newDep: number) => void
|
|
177
|
+
const oldAtom = atom('old', 'old-value')
|
|
178
|
+
const newAtom = atom('new', 'new-value')
|
|
179
|
+
|
|
180
|
+
function Component() {
|
|
181
|
+
const [currentDep, setCurrentDep] = useState(dep)
|
|
182
|
+
setDep = setCurrentDep
|
|
183
|
+
|
|
184
|
+
useQuickReactor(
|
|
185
|
+
'test-reactor',
|
|
186
|
+
() => {
|
|
187
|
+
const atomToUse = currentDep === 1 ? oldAtom : newAtom
|
|
188
|
+
mockEffectFn(atomToUse.get())
|
|
189
|
+
},
|
|
190
|
+
[currentDep]
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return <div>dep: {currentDep}</div>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await act(() => {
|
|
197
|
+
render(<Component />)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
expect(mockEffectFn).toHaveBeenCalledWith('old-value')
|
|
201
|
+
|
|
202
|
+
// Change the old atom - should trigger effect
|
|
203
|
+
await act(() => {
|
|
204
|
+
oldAtom.set('old-updated')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
|
208
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith('old-updated')
|
|
209
|
+
|
|
210
|
+
// Change dependency - should cleanup old scheduler and create new one
|
|
211
|
+
await act(() => {
|
|
212
|
+
setDep(2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(3)
|
|
216
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith('new-value')
|
|
217
|
+
|
|
218
|
+
// Old atom changes should no longer trigger effects
|
|
219
|
+
await act(() => {
|
|
220
|
+
oldAtom.set('old-should-not-trigger')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(3) // No additional calls
|
|
224
|
+
|
|
225
|
+
// New atom changes should trigger effects
|
|
226
|
+
await act(() => {
|
|
227
|
+
newAtom.set('new-updated')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(4)
|
|
231
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith('new-updated')
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('multiple reactors', () => {
|
|
236
|
+
it('supports multiple reactors in the same component', async () => {
|
|
237
|
+
let atom1: Atom<number>
|
|
238
|
+
let atom2: Atom<string>
|
|
239
|
+
const effect1 = vi.fn()
|
|
240
|
+
const effect2 = vi.fn()
|
|
241
|
+
|
|
242
|
+
function Component() {
|
|
243
|
+
atom1 = useAtom('atom1', 1)
|
|
244
|
+
atom2 = useAtom('atom2', 'a')
|
|
245
|
+
|
|
246
|
+
useQuickReactor(
|
|
247
|
+
'reactor1',
|
|
248
|
+
() => {
|
|
249
|
+
effect1(atom1.get())
|
|
250
|
+
},
|
|
251
|
+
[]
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
useQuickReactor(
|
|
255
|
+
'reactor2',
|
|
256
|
+
() => {
|
|
257
|
+
effect2(atom2.get())
|
|
258
|
+
},
|
|
259
|
+
[]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div>
|
|
264
|
+
{atom1.get()} {atom2.get()}
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await act(() => {
|
|
270
|
+
render(<Component />)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(effect1).toHaveBeenCalledWith(1)
|
|
274
|
+
expect(effect2).toHaveBeenCalledWith('a')
|
|
275
|
+
|
|
276
|
+
// Change first atom
|
|
277
|
+
await act(() => {
|
|
278
|
+
atom1!.set(2)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(effect1).toHaveBeenCalledTimes(2)
|
|
282
|
+
expect(effect1).toHaveBeenLastCalledWith(2)
|
|
283
|
+
expect(effect2).toHaveBeenCalledTimes(1) // Should not be affected
|
|
284
|
+
|
|
285
|
+
// Change second atom
|
|
286
|
+
await act(() => {
|
|
287
|
+
atom2!.set('b')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
expect(effect1).toHaveBeenCalledTimes(2) // Should not be affected
|
|
291
|
+
expect(effect2).toHaveBeenCalledTimes(2)
|
|
292
|
+
expect(effect2).toHaveBeenLastCalledWith('b')
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
describe('complex scenarios', () => {
|
|
297
|
+
it('handles complex dependency tracking with multiple atoms', async () => {
|
|
298
|
+
let atom1: Atom<number>
|
|
299
|
+
let atom2: Atom<number>
|
|
300
|
+
let atom3: Atom<number>
|
|
301
|
+
|
|
302
|
+
function Component() {
|
|
303
|
+
atom1 = useAtom('a', 1)
|
|
304
|
+
atom2 = useAtom('b', 2)
|
|
305
|
+
atom3 = useAtom('c', 3)
|
|
306
|
+
|
|
307
|
+
useQuickReactor(
|
|
308
|
+
'complex-reactor',
|
|
309
|
+
() => {
|
|
310
|
+
const sum = atom1.get() + atom2.get() + atom3.get()
|
|
311
|
+
mockEffectFn(sum)
|
|
312
|
+
},
|
|
313
|
+
[]
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return <div>sum</div>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await act(() => {
|
|
320
|
+
render(<Component />)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
expect(mockEffectFn).toHaveBeenCalledWith(6) // 1 + 2 + 3
|
|
324
|
+
|
|
325
|
+
// Change each atom individually
|
|
326
|
+
await act(() => {
|
|
327
|
+
atom1!.set(10)
|
|
328
|
+
})
|
|
329
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(15) // 10 + 2 + 3
|
|
330
|
+
|
|
331
|
+
await act(() => {
|
|
332
|
+
atom2!.set(20)
|
|
333
|
+
})
|
|
334
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(33) // 10 + 20 + 3
|
|
335
|
+
|
|
336
|
+
await act(() => {
|
|
337
|
+
atom3!.set(30)
|
|
338
|
+
})
|
|
339
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(60) // 10 + 20 + 30
|
|
340
|
+
|
|
341
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(4)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('works with conditional atom access', async () => {
|
|
345
|
+
let toggleAtom: Atom<boolean>
|
|
346
|
+
let atom1: Atom<string>
|
|
347
|
+
let atom2: Atom<string>
|
|
348
|
+
|
|
349
|
+
function Component() {
|
|
350
|
+
toggleAtom = useAtom('toggle', true)
|
|
351
|
+
atom1 = useAtom('atom1', 'value1')
|
|
352
|
+
atom2 = useAtom('atom2', 'value2')
|
|
353
|
+
|
|
354
|
+
useQuickReactor(
|
|
355
|
+
'conditional-reactor',
|
|
356
|
+
() => {
|
|
357
|
+
const useFirst = toggleAtom.get()
|
|
358
|
+
const value = useFirst ? atom1.get() : atom2.get()
|
|
359
|
+
mockEffectFn(value)
|
|
360
|
+
},
|
|
361
|
+
[]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return <div>conditional</div>
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await act(() => {
|
|
368
|
+
render(<Component />)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(mockEffectFn).toHaveBeenCalledWith('value1')
|
|
372
|
+
|
|
373
|
+
// Change atom1 - should trigger since toggle is true
|
|
374
|
+
await act(() => {
|
|
375
|
+
atom1!.set('new-value1')
|
|
376
|
+
})
|
|
377
|
+
expect(mockEffectFn).toHaveBeenCalledWith('new-value1')
|
|
378
|
+
|
|
379
|
+
// Change atom2 - should NOT trigger since toggle is true
|
|
380
|
+
await act(() => {
|
|
381
|
+
atom2!.set('new-value2')
|
|
382
|
+
})
|
|
383
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2) // No new call
|
|
384
|
+
|
|
385
|
+
// Toggle to false - should now use atom2
|
|
386
|
+
await act(() => {
|
|
387
|
+
toggleAtom!.set(false)
|
|
388
|
+
})
|
|
389
|
+
expect(mockEffectFn).toHaveBeenCalledWith('new-value2')
|
|
390
|
+
|
|
391
|
+
// Now changes to atom1 should not trigger
|
|
392
|
+
await act(() => {
|
|
393
|
+
atom1!.set('ignored-value1')
|
|
394
|
+
})
|
|
395
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(3) // No new call
|
|
396
|
+
|
|
397
|
+
// But changes to atom2 should trigger
|
|
398
|
+
await act(() => {
|
|
399
|
+
atom2!.set('final-value2')
|
|
400
|
+
})
|
|
401
|
+
expect(mockEffectFn).toHaveBeenCalledWith('final-value2')
|
|
402
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(4)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
})
|
|
@@ -1,7 +1,46 @@
|
|
|
1
1
|
import { EMPTY_ARRAY, EffectScheduler } from '@tldraw/state'
|
|
2
2
|
import { useEffect } from 'react'
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* A React hook that runs side effects immediately in response to signal changes, without throttling.
|
|
6
|
+
* Unlike useReactor which batches updates to animation frames, useQuickReactor executes the effect
|
|
7
|
+
* function immediately when dependencies change, making it ideal for critical updates that cannot wait.
|
|
8
|
+
*
|
|
9
|
+
* The effect runs immediately when the component mounts and whenever tracked signals change.
|
|
10
|
+
* Updates are not throttled, so the effect executes synchronously on every change.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* function DataSynchronizer() {
|
|
15
|
+
* const criticalData = useAtom('criticalData', null)
|
|
16
|
+
*
|
|
17
|
+
* useQuickReactor('sync-data', () => {
|
|
18
|
+
* const data = criticalData.get()
|
|
19
|
+
* if (data) {
|
|
20
|
+
* // Send immediately - don't wait for next frame
|
|
21
|
+
* sendToServer(data)
|
|
22
|
+
* }
|
|
23
|
+
* }, [criticalData])
|
|
24
|
+
*
|
|
25
|
+
* return <div>Sync status updated</div>
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* function CursorUpdater({ editor }) {
|
|
32
|
+
* useQuickReactor('update-cursor', () => {
|
|
33
|
+
* const cursor = editor.getInstanceState().cursor
|
|
34
|
+
* document.body.style.cursor = cursor.type
|
|
35
|
+
* }, [])
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @param name - A descriptive name for the reactor, used for debugging and performance profiling
|
|
40
|
+
* @param reactFn - The effect function to execute when signals change. Should not return a value.
|
|
41
|
+
* @param deps - Optional dependency array that controls when the reactor is recreated. Works like useEffect deps.
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
5
44
|
export function useQuickReactor(name: string, reactFn: () => void, deps: any[] = EMPTY_ARRAY) {
|
|
6
45
|
useEffect(() => {
|
|
7
46
|
const scheduler = new EffectScheduler(name, reactFn)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { act, render, RenderResult } from '@testing-library/react'
|
|
2
|
+
import { Atom } from '@tldraw/state'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { vi } from 'vitest'
|
|
5
|
+
import { useAtom } from './useAtom'
|
|
6
|
+
import { useReactor } from './useReactor'
|
|
7
|
+
|
|
8
|
+
describe('useReactor', () => {
|
|
9
|
+
let mockEffectFn: ReturnType<typeof vi.fn>
|
|
10
|
+
let view: RenderResult
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockEffectFn = vi.fn()
|
|
14
|
+
vi.clearAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('executes the effect function immediately on mount', async () => {
|
|
18
|
+
function Component() {
|
|
19
|
+
useReactor('test-reactor', mockEffectFn, [])
|
|
20
|
+
return <div>test</div>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await act(() => {
|
|
24
|
+
render(<Component />)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('executes the effect function when tracked signals change', async () => {
|
|
31
|
+
let theAtom: Atom<number>
|
|
32
|
+
function Component() {
|
|
33
|
+
theAtom = useAtom('counter', 0)
|
|
34
|
+
useReactor(
|
|
35
|
+
'test-reactor',
|
|
36
|
+
() => {
|
|
37
|
+
mockEffectFn(theAtom.get())
|
|
38
|
+
},
|
|
39
|
+
[]
|
|
40
|
+
)
|
|
41
|
+
return <div>{theAtom.get()}</div>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await act(() => {
|
|
45
|
+
view = render(<Component />)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
|
49
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(0)
|
|
50
|
+
|
|
51
|
+
// Change the atom value - should trigger throttled effect
|
|
52
|
+
await act(() => {
|
|
53
|
+
theAtom!.set(5)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// In test mode, throttleToNextFrame executes immediately
|
|
57
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
|
58
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith(5)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('recreates the reactor when dependencies change', async () => {
|
|
62
|
+
let setDep: (newDep: string) => void
|
|
63
|
+
|
|
64
|
+
function Component() {
|
|
65
|
+
const [currentDep, setCurrentDep] = useState('dep1')
|
|
66
|
+
setDep = setCurrentDep
|
|
67
|
+
|
|
68
|
+
useReactor(
|
|
69
|
+
'test-reactor',
|
|
70
|
+
() => {
|
|
71
|
+
mockEffectFn(currentDep)
|
|
72
|
+
},
|
|
73
|
+
[currentDep]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return <div>{currentDep}</div>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await act(() => {
|
|
80
|
+
render(<Component />)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(mockEffectFn).toHaveBeenCalledWith('dep1')
|
|
84
|
+
|
|
85
|
+
// Change dependency - should recreate reactor
|
|
86
|
+
await act(() => {
|
|
87
|
+
setDep('dep2')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
|
91
|
+
expect(mockEffectFn).toHaveBeenLastCalledWith('dep2')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('cleans up effects when component unmounts', async () => {
|
|
95
|
+
let theAtom: Atom<number>
|
|
96
|
+
|
|
97
|
+
function Component() {
|
|
98
|
+
theAtom = useAtom('counter', 0)
|
|
99
|
+
useReactor(
|
|
100
|
+
'test-reactor',
|
|
101
|
+
() => {
|
|
102
|
+
mockEffectFn(theAtom.get())
|
|
103
|
+
},
|
|
104
|
+
[]
|
|
105
|
+
)
|
|
106
|
+
return <div>{theAtom.get()}</div>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await act(() => {
|
|
110
|
+
view = render(<Component />)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(mockEffectFn).toHaveBeenCalledWith(0)
|
|
114
|
+
|
|
115
|
+
// Unmount the component
|
|
116
|
+
await act(() => {
|
|
117
|
+
view.unmount()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Try to change the atom after unmount - effect should not run
|
|
121
|
+
await act(() => {
|
|
122
|
+
theAtom!.set(10)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Should still only have been called once (during mount)
|
|
126
|
+
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
|
127
|
+
})
|
|
128
|
+
})
|