@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.
@@ -2,23 +2,65 @@
2
2
  import { Computed, ComputedOptions, computed } from '@tldraw/state'
3
3
  import { useMemo } from 'react'
4
4
 
5
- /** @public */
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 and returns it. The computed signal will be created only once.
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
- * type GreeterProps = {
14
- * firstName: Signal<string>
15
- * lastName: Signal<string>
16
- * }
49
+ * function ShoppingCart() {
50
+ * const items = useAtom('items', [])
17
51
  *
18
- * const Greeter = track(function Greeter ({firstName, lastName}: GreeterProps) {
19
- * const fullName = useComputed('fullName', () => `${firstName.get()} ${lastName.get()}`)
20
- * return <div>Hello {fullName.get()}!</div>
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
- /** @public */
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
- /** @public */
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
+ })