@rbxts/deep-charm 0.1.0-rc.1

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/Charm.luau ADDED
@@ -0,0 +1,5 @@
1
+ if not script then
2
+ return require("../charm/src")
3
+ end
4
+
5
+ return require(script.Parent.Parent.charm.src)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright 2024-present Littensy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,1058 @@
1
+ <p align="center">
2
+ <p align="center">
3
+ <img width="150" height="150" src="https://raw.githubusercontent.com/littensy/charm/main/assets/logo.png" alt="Logo">
4
+ </p>
5
+ <h1 align="center"><b>Charm</b></h1>
6
+ <p align="center">
7
+ Fine-grained reactivity for Roblox
8
+ <br />
9
+ <a href="https://npmjs.com/package/@rbxts/charm"><strong>npm package →</strong></a>
10
+ </p>
11
+ </p>
12
+
13
+ <div align="center">
14
+
15
+ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github)
16
+ [![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm)
17
+ [![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
18
+
19
+ </div>
20
+
21
+ Charm is a state management library based on [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates.
22
+
23
+ **Build game state from simple building blocks:**
24
+
25
+ - Store state in [signals](#signalinitialvalue-equals): state containers that hold a value
26
+ - React to state changes with [effects](#effectcallback): re-run code when a dependency updates
27
+ - Derive values from state with [computed signals](#computedgetter): memoized functions with dependency tracking
28
+
29
+ **Want to learn more about signals?**
30
+
31
+ - https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf
32
+ - https://preactjs.com/blog/introducing-signals
33
+ - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
34
+ - https://angular.dev/guide/signals
35
+
36
+ [Migrating from an older version of Charm?](#migration)
37
+
38
+ <details>
39
+ <summary><b>Table of Contents</b></summary>
40
+
41
+ - [Installation](#installation)
42
+ - [Reference](#reference)
43
+ - [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
44
+ - [`computed(getter)`](#computedgetter)
45
+ - [`effect(callback)`](#effectcallback)
46
+ - [Nested effects](#nested-effects)
47
+ - [`effectScope(callback)`](#effectscopecallback)
48
+ - [`listen(getter, callback)`](#listengetter-callback)
49
+ - [Getter functions](#getter-functions)
50
+ - [`observe(getter, callback)`](#observegetter-callback)
51
+ - [`subscribe(getter, callback)`](#subscribegetter-callback)
52
+ - [`untracked(callback)`](#untrackedcallback)
53
+ - [`batch(callback)`](#batchcallback)
54
+ - [`mapped(getter, mapper)`](#mappedgetter-mapper)
55
+ - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
56
+ - [`atom(initialValue, equals?)`](#atominitialvalue-equals)
57
+ - [`trigger(callback)`](#triggercallback)
58
+ - [`flags`](#flags)
59
+ - [Client-Server Sync](#client-server-sync)
60
+ - [Installation](#installation-1)
61
+ - [Quick Start](#quick-start)
62
+ - [Server API](#config)
63
+ - [Client API](#clientaddsignalssetters)
64
+ - [Sync Caveats](#sync-caveats)
65
+ - [Deep Reactivity](#deep-reactivity)
66
+ - [Installation](#installation-2)
67
+ - [`reactive(initialValue)`](#reactiveinitialvalue)
68
+ - [Mutation vs. update function](#mutation-vs-update-function)
69
+ - [`toRaw(value)`](#torawvalue)
70
+ - [`isReactive(value)`](#isreactivevalue)
71
+ - [Migration](#migration)
72
+ - [Examples](#examples)
73
+
74
+ </details>
75
+
76
+ ## At a Glance
77
+
78
+ ```luau
79
+ local getTodos, setTodos = signal({} :: { string })
80
+ local getQuery, setQuery = signal("")
81
+
82
+ observe(getTodos, function(todo: string, index: number)
83
+ local instance = Instance.new("TextLabel")
84
+
85
+ local getText = computed(function()
86
+ return getTodos()[index] or ""
87
+ end)
88
+
89
+ effect(function()
90
+ instance.Text = getText()
91
+ instance.Visible = string.match(getText(), getQuery()) ~= nil
92
+ end)
93
+
94
+ instance.LayoutOrder = index
95
+ instance.Size = UDim2.new(1, 0, 0, 40)
96
+ instance.Parent = screenGui
97
+
98
+ return function()
99
+ instance:Destroy()
100
+ end
101
+ end)
102
+
103
+ setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
104
+ setQuery("Buy")
105
+ ```
106
+
107
+ <details>
108
+ <summary>Explain code</summary>
109
+
110
+ ```luau
111
+ -- Declare state for a todo list and a search query
112
+ local getTodos, setTodos = signal({} :: { string })
113
+ local getQuery, setQuery = signal("")
114
+
115
+ -- Create a text label when an item is added to the list
116
+ observe(getTodos, function(todo, index)
117
+ local instance = Instance.new("TextLabel")
118
+
119
+ -- Create a memoized function that only re-runs effects when the todo list
120
+ -- updates or the function returns new text
121
+ local getText = computed(function()
122
+ return getTodos()[index] or ""
123
+ end)
124
+
125
+ -- Update instance properties when the text or query updates
126
+ effect(function()
127
+ instance.Text = getText()
128
+ instance.Visible = string.match(getText(), getQuery()) ~= nil
129
+ end)
130
+
131
+ instance.LayoutOrder = index
132
+ instance.Size = UDim2.new(1, 0, 0, 40)
133
+ instance.Parent = screenGui
134
+
135
+ -- Destroy the instance when this item is removed
136
+ return function()
137
+ instance:Destroy()
138
+ end
139
+ end)
140
+
141
+ -- Add items to the todo list
142
+ setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
143
+ -- Filter for items containing "Buy"
144
+ setQuery("Buy")
145
+ ```
146
+
147
+ </details>
148
+
149
+ ## Installation
150
+
151
+ ```sh
152
+ npm install @rbxts/charm
153
+ yarn add @rbxts/charm
154
+ pnpm add @rbxts/charm
155
+ ```
156
+
157
+ ```toml
158
+ # wally.toml
159
+ [dependencies]
160
+ Charm = "littensy/charm@VERSION"
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Reference
166
+
167
+ ### `signal(initialValue, equals?)`
168
+
169
+ Signals are the core of reactivity in Charm. The `signal` function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value.
170
+
171
+ ```luau
172
+ local getCounter, setCounter = signal(0)
173
+
174
+ setCounter(1)
175
+ setCounter(function(count)
176
+ return count + 1
177
+ end)
178
+ print(getCounter()) -- 2
179
+ ```
180
+
181
+ Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all of your state is correct and up-to-date.
182
+
183
+ You can also pass a custom equality function to only update the signal if the equality function returns `false`:
184
+
185
+ ```luau
186
+ local getMax, setMax = signal(0, function(current, incoming)
187
+ return incoming <= current
188
+ end)
189
+
190
+ setMax(1) -- 1
191
+ setMax(2) -- 2
192
+ setMax(-1) -- 2
193
+ ```
194
+
195
+ > [!NOTE]
196
+ > Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter.
197
+
198
+ ---
199
+
200
+ ### `effect(callback)`
201
+
202
+ Effects are fundamental to reactivity, allowing you to react to signal updates. The `effect` function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute.
203
+
204
+ ```luau
205
+ local getCounter, setCounter = signal(0)
206
+
207
+ effect(function()
208
+ print(`Count is {getCounter()}`)
209
+ end) -- Count is 0
210
+
211
+ setCounter(1) -- Count is 1
212
+ ```
213
+
214
+ You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed:
215
+
216
+ ```luau
217
+ local getCounter, setCounter = signal(0)
218
+
219
+ local dispose = effect(function()
220
+ local count = getCounter()
221
+ return function()
222
+ print(`Cleanup {count}`)
223
+ end
224
+ end)
225
+
226
+ setCounter(1) -- Cleanup 0
227
+ dispose() -- Cleanup 1
228
+ ```
229
+
230
+ ### Nested Effects
231
+
232
+ An effect is _nested_ if it was created during the execution of another effect. In Charm, when an effect with nested effects re-runs or gets cleaned up, the nested effects from the previous run are automatically cleaned up and re-created if needed. This prevents memory leaks and ensures that outer effects always run before their inner effects:
233
+
234
+ ```luau
235
+ local getPrintCount, setPrintCount = signal(true)
236
+ local getCount, setCount = signal(1)
237
+
238
+ effect(function()
239
+ if getPrintCount() then
240
+ -- This inner effect is created when getPrintCount() is true
241
+ effect(function()
242
+ print(`Count is {getCount()}`)
243
+ end)
244
+ end
245
+ end) -- Count is 1
246
+
247
+ setCount(2) -- Count is 2
248
+
249
+ -- This re-runs the outer effect and cleans up old inner effects
250
+ setPrintCount(false)
251
+
252
+ setCount(3) -- No output
253
+ ```
254
+
255
+ > [!NOTE]
256
+ > To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging.
257
+
258
+ ---
259
+
260
+ ### `computed(getter)`
261
+
262
+ The `computed` function creates a read-only signal whose value is derived from other signals. The computed signal caches the getter function's last result, and the value is only re-computed if a dependency has updated since the last computation.
263
+
264
+ ```luau
265
+ local getName, setName = signal("John")
266
+ local getSurname, setSurname = signal("Doe")
267
+ local getFullName = computed(function()
268
+ return `{getName()} {getSurname()}`
269
+ end)
270
+
271
+ print(getFullName()) -- "John Doe"
272
+ setName("Jane")
273
+ print(getFullName()) -- "Jane Doe"
274
+ ```
275
+
276
+ The getter function also receives the previous result (or `nil` during the initial run). You can use this for computed signals that depend on the previous result:
277
+
278
+ ```luau
279
+ local getCounter, setCounter = signal(10)
280
+ local getMax = computed(function(prevMax)
281
+ return math.max(getCounter(), prevMax or 0)
282
+ end)
283
+
284
+ print(getMax()) -- 10
285
+ setCounter(5)
286
+ print(getMax()) -- 10
287
+ ```
288
+
289
+ ---
290
+
291
+ ### `effectScope(callback)`
292
+
293
+ Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created during the execution of the callback will clean up when the scope disposes.
294
+
295
+ ```luau
296
+ local getCounter, setCounter = signal(0)
297
+
298
+ local dispose = effectScope(function()
299
+ effect(function()
300
+ print(`Count 1 is {getCounter()}`)
301
+ end)
302
+ effect(function()
303
+ print(`Count 2 is {getCounter()}`)
304
+ end)
305
+ end)
306
+
307
+ setCounter(1) -- Count 1 is 1, Count 2 is 1
308
+ dispose()
309
+ setCounter(2) -- No output; effects got disposed
310
+ ```
311
+
312
+ Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed.
313
+
314
+ ---
315
+
316
+ ### `listen(getter, callback)`
317
+
318
+ The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects.
319
+
320
+ The callback also receives the previous value, or `nil` when running for the first time.
321
+
322
+ ```luau
323
+ local getCounter, setCounter = signal(0)
324
+
325
+ listen(getCounter, function(count, prevCount)
326
+ print(`Count is {count} (was {prevCount})`)
327
+ end) -- Count is 0 (was nil)
328
+
329
+ setCounter(1) -- Count is 1 (was 0)
330
+ ```
331
+
332
+ Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up.
333
+
334
+ ### Getter functions
335
+
336
+ In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked:
337
+
338
+ ```luau
339
+ local getCounter, setCounter = signal(0)
340
+
341
+ local function floorCounter()
342
+ return math.floor(getCounter())
343
+ end
344
+
345
+ listen(floorCounter, function(count, prevCount)
346
+ print(`Floor of count is {count} (was {prevCount})`)
347
+ end) -- Floor of count is 0 (was nil)
348
+
349
+ setCounter(0.5) -- Doesn't print anything, floor is still 0
350
+ setCounter(1) -- Floor of count is 1 (was 0)
351
+ ```
352
+
353
+ ---
354
+
355
+ ### `observe(getter, callback)`
356
+
357
+ [Observers](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) allow you to track the lifetime of a given state. The `observe` function executes the callback for every unique key added to a table, and disposes the callback when that key is removed.
358
+
359
+ ```luau
360
+ local getItems, setItems = signal({ a = 0, b = 0 })
361
+
362
+ observe(getItems, function(value, key)
363
+ print(`Added {key}`)
364
+ return function()
365
+ print(`Removed {key}`)
366
+ end
367
+ end) -- Added a, Added b
368
+
369
+ setItems({ a = 0, c = 0 }) -- Removed b, Added c
370
+ ```
371
+
372
+ The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:
373
+
374
+ ```luau
375
+ local getItems, setItems = signal({})
376
+
377
+ local dispose = observe(getItems, function(value, key)
378
+ local getValue = computed(function(prevValue)
379
+ return getItems()[key] or prevValue
380
+ end)
381
+
382
+ effect(function()
383
+ local value = getValue()
384
+ print(`Set {key} = {value}`)
385
+ return function()
386
+ print(`Cleanup {key} = {value}`)
387
+ end
388
+ end)
389
+ end)
390
+
391
+ setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0
392
+ setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1
393
+ setItems({ a = 1 }) -- Cleanup b = 0
394
+ dispose() -- Cleanup a = 1
395
+ ```
396
+
397
+ ---
398
+
399
+ ### `subscribe(getter, callback)`
400
+
401
+ The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes.
402
+
403
+ ```luau
404
+ local getCounter, setCounter = signal(0)
405
+
406
+ -- Does not output anything initially
407
+ subscribe(getCounter, function(count, prevCount)
408
+ print(`Count is {count} (was {prevCount})`)
409
+ end)
410
+
411
+ setCounter(1) -- Count is 1 (was 0)
412
+ ```
413
+
414
+ ---
415
+
416
+ ### `untracked(callback)`
417
+
418
+ In case you want to opt-out of dependency tracking in an effect, you can use `untracked()` to call a function _outside_ the current scope, preventing signals and effects in the function from being tracked.
419
+
420
+ ```luau
421
+ local getTracked, setTracked = signal(0)
422
+ local getUntracked, setUntracked = signal(0)
423
+
424
+ effect(function()
425
+ print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
426
+ end) -- Tracked: 0, Untracked: 0
427
+
428
+ setTracked(1) -- Tracked: 1, Untracked: 0
429
+ setUntracked(1) -- No output
430
+ setTracked(2) -- Tracked: 2, Untracked: 1
431
+ ```
432
+
433
+ Because `untracked()` executes the callback outside the current effect, nested effects created during the execution of the callback will not be tracked by the parent effect:
434
+
435
+ ```luau
436
+ local stopEffect
437
+ local stopScope = effectScope(function()
438
+ untracked(function()
439
+ stopEffect = effect(function()
440
+ return function()
441
+ print("Cleaned up untracked effect")
442
+ end
443
+ end)
444
+ end)
445
+ end)
446
+
447
+ stopScope() -- No output, the scope did not track the effect
448
+ stopEffect() -- Cleaned up untracked effect
449
+ ```
450
+
451
+ ---
452
+
453
+ ### `batch(callback)`
454
+
455
+ Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
456
+
457
+ ```luau
458
+ local getName, setName = signal("John")
459
+ local getSurname, setSurname = signal("Doe")
460
+
461
+ effect(function()
462
+ print(`Full name: {getName()} {getSurname()}`)
463
+ end)
464
+
465
+ -- Combines both writes into a single update.
466
+ -- Once the callback completes, outputs "Full name: Foo Bar"
467
+ batch(function()
468
+ setName("Foo")
469
+ setSurname("Bar")
470
+ end)
471
+ ```
472
+
473
+ ---
474
+
475
+ ### `mapped(getter, mapper)`
476
+
477
+ The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value.
478
+
479
+ The first value returned by the mapper is used as the new value:
480
+
481
+ ```luau
482
+ local getList, setList = signal({ "a", "b", "c" })
483
+
484
+ local getUppercase = mapped(getList, function(value)
485
+ return string.upper(value)
486
+ end)
487
+
488
+ print(getUppercase()) -- { "A", "B", "C" }
489
+ ```
490
+
491
+ If the mapper returns two values, the second value is used as the new key:
492
+
493
+ ```luau
494
+ local getList, setList = signal({ "a", "b", "c" })
495
+
496
+ local getSwapped = mapped(getList, function(value, key)
497
+ return key, value
498
+ end)
499
+
500
+ print(getSwapped()) -- { a = 1, b = 2, c = 3 }
501
+ ```
502
+
503
+ ---
504
+
505
+ ### `onCleanup(callback, failSilently?)`
506
+
507
+ The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect.
508
+
509
+ Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope.
510
+
511
+ ```luau
512
+ local dispose = effectScope(function()
513
+ onCleanup(function()
514
+ print("Cleaned up")
515
+ end)
516
+ end)
517
+
518
+ dispose() -- Cleaned up
519
+ ```
520
+
521
+ ---
522
+
523
+ ### `atom(initialValue, equals?)`
524
+
525
+ The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter.
526
+
527
+ If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value.
528
+
529
+ ```luau
530
+ local counter = atom(0)
531
+
532
+ print(counter()) -- 0
533
+ counter(1)
534
+ counter(function(count)
535
+ return count + 1
536
+ end)
537
+ ```
538
+
539
+ You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value:
540
+
541
+ ```luau
542
+ local max = atom(0, function(current, incoming)
543
+ return incoming <= current
544
+ end)
545
+
546
+ max(1) -- 1
547
+ max(-1) -- 1
548
+ ```
549
+
550
+ ---
551
+
552
+ ### `trigger(callback)`
553
+
554
+ The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter:
555
+
556
+ ```luau
557
+ local array = signal({} :: { number })
558
+ local length = computed(function()
559
+ return #array()
560
+ end)
561
+
562
+ print(length()) -- 0
563
+
564
+ -- Direct mutation doesn't automatically trigger updates
565
+ table.insert(array(), 1)
566
+ print(length()) -- Still 0
567
+
568
+ -- Manually trigger updates
569
+ trigger(array)
570
+ print(length()) -- 1
571
+ ```
572
+
573
+ You can also trigger multiple signals at once:
574
+
575
+ ```luau
576
+ local src1 = signal({} :: { number })
577
+ local src2 = signal({} :: { number })
578
+ local total = computed(function()
579
+ return #src1() + #src2()
580
+ end)
581
+
582
+ table.insert(src1(), 1)
583
+ table.insert(src2(), 2)
584
+
585
+ trigger(function()
586
+ src1()
587
+ src2()
588
+ end)
589
+
590
+ print(total()) -- 2
591
+ ```
592
+
593
+ ---
594
+
595
+ ### `flags`
596
+
597
+ Charm exposes the following global flags to customize behavior:
598
+
599
+ | Flag | Default | Description |
600
+ | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
601
+ | strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
602
+ | frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. |
603
+ | trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
604
+
605
+ The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower.
606
+
607
+ ---
608
+
609
+ ## Client-Server Sync
610
+
611
+ ### Installation
612
+
613
+ ```sh
614
+ npm install @rbxts/charm-sync
615
+ yarn add @rbxts/charm-sync
616
+ pnpm add @rbxts/charm-sync
617
+ ```
618
+
619
+ ```toml
620
+ [dependencies]
621
+ CharmSync = "littensy/charm-sync@VERSION"
622
+ ```
623
+
624
+ ### Quick Start
625
+
626
+ Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals:
627
+
628
+ ```luau
629
+ -- nameStore
630
+ local getName, setName = signal("John")
631
+ local getSurname, setSurname = signal("Doe")
632
+ local ageAtom = atom(20)
633
+
634
+ return {
635
+ getName = getName,
636
+ setName = setName,
637
+ getSurname = getSurname,
638
+ setSurname = setSurname,
639
+ ageAtom = ageAtom,
640
+ }
641
+ ```
642
+
643
+ When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
644
+
645
+ Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to.
646
+
647
+ ```luau
648
+ local function onPlayerAdded(player: Player)
649
+ -- Add signal getters, computed signals, atoms, or reactive objects
650
+ server.addSignalsToClient(player, {
651
+ name = nameStore.getName,
652
+ surname = nameStore.getSurname,
653
+ age = nameStore.ageAtom,
654
+ })
655
+ end
656
+
657
+ for _, player in Players:GetPlayers() do
658
+ onPlayerAdded(player)
659
+ end
660
+
661
+ Players.PlayerAdded:Connect(onPlayerAdded)
662
+
663
+ Players.PlayerRemoving:Connect(function(player)
664
+ server.removeClient(player)
665
+ end)
666
+
667
+ server.connect(function(player, updates)
668
+ -- Customize how you send state updates to clients
669
+ syncEvent:FireClient(player, updates)
670
+ end)
671
+ ```
672
+
673
+ > [!NOTE]
674
+ > On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning.
675
+
676
+ To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts.
677
+
678
+ After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
679
+
680
+ ```luau
681
+ -- Add signal setters, atoms, or reactive proxies
682
+ client.addSignals({
683
+ name = nameStore.setName,
684
+ surname = nameStore.setSurname,
685
+ age = nameStore.ageAtom,
686
+ })
687
+
688
+ -- Update client signals when receiving updates from the server
689
+ syncEvent.OnClientEvent:Connect(function(updates)
690
+ client.patch(updates)
691
+ end)
692
+ ```
693
+
694
+ ---
695
+
696
+ ### `config`
697
+
698
+ A configuration table that customizes the behavior of Charm Sync on the server.
699
+
700
+ | Option | Default | Description |
701
+ | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
702
+ | interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. |
703
+ | preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. |
704
+ | fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
705
+ | validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). |
706
+
707
+ ---
708
+
709
+ ### `server.addSignalsToClient(client, getters)`
710
+
711
+ The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed.
712
+
713
+ You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals.
714
+
715
+ ```luau
716
+ Players.PlayerAdded:Connect(function(player)
717
+ server.addSignalsToClient(player, {
718
+ name = nameStore.getName,
719
+ surname = nameStore.getSurname,
720
+ age = nameStore.ageAtom,
721
+ })
722
+ end)
723
+ ```
724
+
725
+ You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player:
726
+
727
+ ```luau
728
+ server.addSignalsToClient(player, {
729
+ [`data-{player.UserId}`] = computed(function()
730
+ return getPlayerData(player.UserId)
731
+ end),
732
+ })
733
+ ```
734
+
735
+ ---
736
+
737
+ ### `server.removeSignalsFromClient(client, ...keys)`
738
+
739
+ The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`.
740
+
741
+ ```luau
742
+ server.addSignalsToClient(player, {
743
+ name = nameStore.getName,
744
+ surname = nameStore.getSurname,
745
+ })
746
+
747
+ server.removeSignalsFromClient(player, "surname")
748
+ ```
749
+
750
+ ---
751
+
752
+ ### `server.removeClient(client)`
753
+
754
+ The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game.
755
+
756
+ ```luau
757
+ Players.PlayerAdded:Connect(function(player)
758
+ server.addSignalsToClient(player, signals)
759
+ end)
760
+
761
+ Players.PlayerRemoving:Connect(function(player)
762
+ server.removeClient(player)
763
+ end)
764
+ ```
765
+
766
+ ---
767
+
768
+ ### `server.connect(onSync)`
769
+
770
+ Binds a callback to run when sending state updates a client. Scheduled updates will be sent periodically at the interval specified in [`config.interval`](#config).
771
+
772
+ When a synced signal updates, the `onSync` function is scheduled to run for each client subscribed to that signal on the next sync event.
773
+
774
+ ```luau
775
+ server.connect(function(player, updates)
776
+ syncEvent:FireServer(player, updates)
777
+ end)
778
+ ```
779
+
780
+ ---
781
+
782
+ ### `server.disconnect()`
783
+
784
+ Stops syncing state updates to clients at the automatic interval. This doesn't unbind the last callback, so you can still manually trigger sync events after calling this function by calling `server.flush()`.
785
+
786
+ ```luau
787
+ server.connect(function(player, updates)
788
+ syncEvent:FireServer(player, updates)
789
+ end)
790
+
791
+ -- Stops calling the function at the automatic interval
792
+ server.disconnect()
793
+
794
+ -- Flushing still sends pending updates
795
+ server.flush()
796
+ ```
797
+
798
+ ---
799
+
800
+ ### `client.addSignals(setters)`
801
+
802
+ Subscribes the given writable signals to the states with the corresponding keys on the server. When the server sends updates, the functions associated with each key in the state will be called with the patched values.
803
+
804
+ You can pass either writable signals, atoms, or [reactive objects](#reactiveinitialvalue) to this function:
805
+
806
+ ```luau
807
+ client.addSignals({
808
+ name = nameStore.setName,
809
+ surname = nameStore.setSurname,
810
+ age = nameStore.ageAtom,
811
+ })
812
+ ```
813
+
814
+ ---
815
+
816
+ ### `client.removeSignals(...keys)`
817
+
818
+ Unsubscribes from each signal with the corresponding keys. The signals will retain their current values, but will no longer receive updates from the server.
819
+
820
+ ```luau
821
+ client.addSignals({
822
+ name = nameStore.setName,
823
+ surname = nameStore.setSurname,
824
+ })
825
+
826
+ client.removeSignals("name")
827
+ ```
828
+
829
+ ---
830
+
831
+ ### `client.patch(updates)`
832
+
833
+ The `patch` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed.
834
+
835
+ You should call `patch` when receiving updates from the server from a remote event:
836
+
837
+ ```luau
838
+ client.addSignals({
839
+ name = nameStore.setName,
840
+ surname = nameStore.setSurname,
841
+ })
842
+
843
+ syncEvent.OnClientEvent:Connect(function(updates)
844
+ client.patch(updates)
845
+ end)
846
+ ```
847
+
848
+ ---
849
+
850
+ ### `signalToAtom(getter, setter)`
851
+
852
+ If you have a lot of signals to sync between the server and clients, it might become difficult to keep track of many getters and setters. The `signalToAtom()` function unifies a signal's `get()` and `set()` functions, allowing you to reuse the same values for `client.addSignals` and `server.addSignalsToClient`.
853
+
854
+ ```luau
855
+ local sharedState = {
856
+ name = signalToAtom(nameStore.getName, nameStore.setName),
857
+ surname = signalToAtom(nameStore.getSurname, nameStore.setSurname),
858
+ }
859
+
860
+ client.addSignals(sharedState)
861
+ server.addSignalsToClient(client, sharedState)
862
+ ```
863
+
864
+ ---
865
+
866
+ ### Sync Caveats
867
+
868
+ Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`.
869
+
870
+ But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`.
871
+
872
+ This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload.
873
+
874
+ ---
875
+
876
+ ## Deep Reactivity
877
+
878
+ Charm's reactivity system is _shallow_ by default: only the top-level value is reactive, so table properties are not checked when determining whether a signal has updated. As a result, tables in Charm should be immutable (copied before writing) in order to signal that a table's properties have changed.
879
+
880
+ Deep reactivity, on the other hand, uses proxy tables to perform dependency tracking on properties. You can subscribe to a property by indexing the proxy table, and setting a property will notify its subscribers. This approach to reactivity lets you work with mutable data, making state management more intuitive at the cost of added overhead.
881
+
882
+ You can opt-in to deep reactivity with the Deep Charm library:
883
+
884
+ ### Installation
885
+
886
+ ```sh
887
+ npm install @rbxts/deep-charm
888
+ yarn add @rbxts/deep-charm
889
+ pnpm add @rbxts/deep-charm
890
+ ```
891
+
892
+ ```toml
893
+ [dependencies]
894
+ DeepCharm = "littensy/deep-charm@VERSION"
895
+ ```
896
+
897
+ ---
898
+
899
+ ### `reactive(initialValue)`
900
+
901
+ The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and nested tables will also be wrapped in a reactive proxy.
902
+
903
+ ```luau
904
+ local users, updateUsers = reactive({
905
+ { name = "John", surname = "Doe" },
906
+ })
907
+
908
+ effect(function()
909
+ print(`{users[1].name} {users[1].surname}`)
910
+ end) -- Output: John Doe
911
+
912
+ updateUsers(function(raw)
913
+ raw[1].name = "Jane"
914
+ raw[1].surname = "Smith"
915
+ table.insert(raw, { name = "Steve", surname = "Doe" })
916
+ end) -- Output: Jane Smith
917
+
918
+ -- You can also mutate the reactive proxy directly:
919
+ users[1].name = "John" -- John Smith
920
+ ```
921
+
922
+ ### Mutation vs. update function
923
+
924
+ Because reactive proxies use metatables for reading and writing, functions like `table.insert` will not work on the proxy. Table functions should only be called on the raw table value, which you can access through the update function returned by `reactive()`.
925
+
926
+ This update function (`updateUsers()` in the example above) passes the raw table for you to mutate. Once your callback is done executing, the updater will manually notify subscriptions to the reactive proxy and its nested properties. This process does not use metatables, so you should use this for table operations or batching updates.
927
+
928
+ ---
929
+
930
+ ### `toRaw(value)`
931
+
932
+ If you need to access the raw table through the reactive proxy, use the `toRaw()` function:
933
+
934
+ ```luau
935
+ local raw = {}
936
+ local proxy = reactive(raw)
937
+
938
+ print(toRaw(proxy) == raw) -- true
939
+ ```
940
+
941
+ ---
942
+
943
+ ### `isReactive(value)`
944
+
945
+ The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable.
946
+
947
+ ```lua
948
+ local raw = {}
949
+ local proxy = reactive(raw)
950
+
951
+ print(isReactive(proxy)) -- true
952
+ print(isReactive(raw)) -- false
953
+ ```
954
+
955
+ ---
956
+
957
+ ## Migration
958
+
959
+ Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version.
960
+
961
+ For reference, a signal is a state container with a separated getter and setter function. Atoms are a signal with a single function for getting and setting the state.
962
+
963
+ **What to look out for:**
964
+
965
+ 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like:
966
+ - `peek()` was changed to `untracked()` for parity with other state managers
967
+ - The second arguments of `atom()` changed from an `options` table to an equality function
968
+ - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value)
969
+ - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`)
970
+
971
+ 2. If you use Charm Sync, you'll have to rewrite a lot of your sync code. Fortunately, most of the changes should make your code _less_ complicated:
972
+ - You can now sync signals per-client, including computed signals. You shouldn't have to modify sync payloads to filter data anymore.
973
+ - Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`.
974
+ - [Read the updated docs for syncing state →](#client-server-sync)
975
+
976
+ 3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors. The flags have the following behaviors:
977
+ - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error
978
+ - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
979
+
980
+ 4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent.
981
+ - This feature applies to the observer function in `observe()`, which also automatically cleans up inner effects to prevent memory leaks. However, this does not apply to `subscribe()` or `listen()`.
982
+ - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback).
983
+ - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`.
984
+
985
+ 5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
986
+ - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit
987
+ - [`listen()`](#listengetter-callback): create a subscription that also runs once immediately
988
+ - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
989
+ - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope
990
+ - [`trigger()`](#triggercallback): trigger updates for table mutations
991
+
992
+ ---
993
+
994
+ ## Examples
995
+
996
+ - https://github.com/littensy/fishing-minigame: Fisch clone using Charm for server and client state
997
+
998
+ ### React Counter
999
+
1000
+ ```luau
1001
+ local Charm = require("@packages/charm")
1002
+ local ReactCharm = require("@packages/react-charm")
1003
+ local React = require("@packages/react")
1004
+
1005
+ local getCounter, setCounter = Charm.signal(0)
1006
+
1007
+ local function Counter()
1008
+ local count = ReactCharm.useSignalState(getCounter)
1009
+
1010
+ return React.createElement("TextButton", {
1011
+ [React.Event.Activated] = function()
1012
+ setCounter(count + 1)
1013
+ end,
1014
+ Text = `Count: {count}`,
1015
+ Size = UDim2.fromOffset(100, 50),
1016
+ })
1017
+ end
1018
+ ```
1019
+
1020
+ ### Vide Counter
1021
+
1022
+ ```luau
1023
+ local Charm = require("@packages/charm")
1024
+ local VideCharm = require("@packages/vide-charm")
1025
+ local Vide = require("@packages/vide")
1026
+
1027
+ local create = Vide.create
1028
+
1029
+ local getCounter, setCounter = Charm.signal(0)
1030
+
1031
+ local function Counter()
1032
+ local count = VideCharm.useSignalState(getCounter)
1033
+
1034
+ return create "TextButton" {
1035
+ Activated = function()
1036
+ setCounter(function(count)
1037
+ return count + 1
1038
+ end)
1039
+ end,
1040
+ Text = function()
1041
+ return `Count: {count()}`
1042
+ end,
1043
+ Size = UDim2.fromOffset(100, 50),
1044
+ }
1045
+ end
1046
+ ```
1047
+
1048
+ ---
1049
+
1050
+ <p align="center">
1051
+ Charm is released under the <a href="LICENSE">MIT License</a>.
1052
+ </p>
1053
+
1054
+ <div align="center">
1055
+
1056
+ [![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE)
1057
+
1058
+ </div>
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@rbxts/deep-charm",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Use Charm with plain Luau tables",
5
+ "license": "MIT",
6
+ "main": "src/init.luau",
7
+ "types": "src/index.d.ts",
8
+ "files": [
9
+ "src",
10
+ "*.luau",
11
+ "!wally.toml",
12
+ "!wally.lock",
13
+ "!default.project.json",
14
+ "!test"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/littensy/charm.git"
19
+ },
20
+ "dependencies": {
21
+ "@rbxts/charm": "^0.11.0-rc.5"
22
+ },
23
+ "scripts": {
24
+ "publish:wally": "wally publish"
25
+ }
26
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ export type ReactiveProxy<T extends object = object> = T & {
2
+ __track: () => void;
3
+ __target: T;
4
+ __signals: Map<unknown, () => unknown>;
5
+ };
6
+
7
+ /**
8
+ * Creates a deep reactive value that tracks properties when they're accessed.
9
+ * Returns a proxy object that subscribes to changes on any nested property
10
+ * you read, and a function for mutating the original table.
11
+ *
12
+ * @example
13
+ * ```luau
14
+ * local users, updateUsers = reactive({
15
+ * user = { name = "John", surname = "Doe" },
16
+ * })
17
+ *
18
+ * effect(function()
19
+ * print(users.user and `{users.user.name} {users.user.surname}`)
20
+ * end)
21
+ *
22
+ * updateUsers(function(state)
23
+ * state.user.name = "Jane"
24
+ * state.user.surname = "Smith"
25
+ * end)
26
+ * ```
27
+ *
28
+ * @param initialValue The value to make deeply reactive.
29
+ * @returns A proxy table and a function to mutate the original table.
30
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
31
+ */
32
+ export function reactive<T extends object>(
33
+ initialValue: T,
34
+ ): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>;
35
+
36
+ /**
37
+ * Determines if the provided value is a reactive proxy created by `reactive()`.
38
+ *
39
+ * @param value A value to test.
40
+ * @returns `true` if the value is a reactive proxy, `false` otherwise.
41
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
42
+ */
43
+ export function isReactive(value: unknown): boolean;
44
+
45
+ /**
46
+ * Unwraps a reactive proxy to get the original table. If the provided value
47
+ * is not a proxy, it is returned as-is.
48
+ *
49
+ * @param value A reactive proxy or a non-proxy value.
50
+ * @returns The unwrapped target value.
51
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
52
+ */
53
+ export function toRaw<T>(value: T): T;
package/src/init.luau ADDED
@@ -0,0 +1,165 @@
1
+ local Charm = require("./Charm")
2
+
3
+ local computed = Charm.computed
4
+ local trigger = Charm.trigger
5
+ local getActiveSub = Charm.getActiveSub
6
+
7
+ export type ReactiveProxy<T = any> = typeof(setmetatable(
8
+ {} :: {
9
+ __track: () -> (),
10
+ __target: T,
11
+ __signals: { [unknown]: () -> unknown },
12
+ },
13
+ {} :: { __index: T }
14
+ ))
15
+
16
+ local ReactiveProxy = {
17
+ proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
18
+ }
19
+
20
+ --[[
21
+ Determines if the provided value is a reactive proxy created by `reactive()`.
22
+
23
+ @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
24
+ ]]
25
+ local function isReactive(value: any): boolean
26
+ return type(value) == "table" and getmetatable(value) == ReactiveProxy
27
+ end
28
+
29
+ --[[
30
+ Unwraps a reactive proxy to get the original table. If the provided value
31
+ is not a proxy, it is returned as-is.
32
+
33
+ @param value A reactive proxy or a non-proxy value
34
+ @return The source table if `value` is a reactive proxy
35
+ @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
36
+ ]]
37
+ local function toRaw<T>(value: T): T
38
+ if isReactive(value) then
39
+ local value = value :: ReactiveProxy<T> & T
40
+ value.__track()
41
+ return toRaw(value.__target)
42
+ end
43
+ return value
44
+ end
45
+
46
+ --[[
47
+ Creates a deep reactive value that tracks properties when they're accessed.
48
+ Returns a proxy object that subscribes to changes on any nested property
49
+ you read, and a function for mutating the original table.
50
+
51
+ @example
52
+ ```luau
53
+ local users, updateUsers = reactive({
54
+ { name = "John", surname = "Doe" },
55
+ })
56
+
57
+ effect(function()
58
+ print(`{users[1].name} {users[1].surname}`)
59
+ end)
60
+
61
+ updateUsers(function(state)
62
+ state[1].name = "Jane"
63
+ state[1].surname = "Smith"
64
+ end)
65
+ ```
66
+
67
+ @param initialValue Source table to make deeply reactive
68
+ @return A proxy table that tracks reads to nested properties
69
+ @return A function for batching mutations to the source table
70
+ @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
71
+ ]]
72
+ local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
73
+ initialValue = toRaw(initialValue)
74
+
75
+ local proxy = ReactiveProxy.proxyMap[initialValue]
76
+
77
+ if not proxy then
78
+ local track = computed(function(version: number?)
79
+ -- Reference the proxy to avoid GCing while an effect is actively
80
+ -- tracking this reactive object.
81
+ local _ref = proxy
82
+ -- Allow updates from the parent to propagate down when the
83
+ -- target is mutated directly through the setter.
84
+ if trackParent then
85
+ trackParent()
86
+ end
87
+ return (version or 0) + 1
88
+ end)
89
+
90
+ proxy = setmetatable({
91
+ __target = initialValue,
92
+ __signals = setmetatable({}, { __mode = "v" }) :: any,
93
+ __track = track,
94
+ }, ReactiveProxy)
95
+
96
+ ReactiveProxy.proxyMap[initialValue] = proxy
97
+ end
98
+
99
+ local function setValue(update: ((T) -> ())?)
100
+ if update then
101
+ update(proxy.__target)
102
+ end
103
+ trigger(proxy.__track)
104
+ end
105
+
106
+ return proxy :: any, setValue
107
+ end
108
+
109
+ --[[
110
+ Wraps a value in `reactive()` if it is a table; otherwise, returns the
111
+ value as-is.
112
+
113
+ @param value Source value to make deeply reactive
114
+ @param trackParent Optional tracking function to propagate deep updates
115
+ @return A reactive proxy if the value is a table
116
+ ]]
117
+ local function toReactive<T>(value: T, trackParent: (() -> ())?): T
118
+ return if type(value) == "table" then reactive(value, trackParent) else value
119
+ end
120
+
121
+ function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
122
+ local track = self.__track
123
+ local signals = self.__signals
124
+ local target = self.__target
125
+
126
+ if getActiveSub() then
127
+ if not signals[key] then
128
+ signals[key] = computed(function()
129
+ track()
130
+ return target[key]
131
+ end)
132
+ end
133
+ signals[key]()
134
+ end
135
+
136
+ return toReactive(target[key], track)
137
+ end
138
+
139
+ function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
140
+ value = toRaw(value)
141
+ if self.__target[key] ~= value then
142
+ self.__target[key] = value
143
+ trigger(self.__track)
144
+ end
145
+ end
146
+
147
+ function ReactiveProxy.__iter(self: ReactiveProxy)
148
+ self.__track()
149
+ local function nextReactive(target: any, prevKey: any)
150
+ local nextKey, nextValue = next(target, prevKey)
151
+ return nextKey, toReactive(nextValue, self.__track)
152
+ end
153
+ return nextReactive, self.__target
154
+ end
155
+
156
+ function ReactiveProxy.__len(self: ReactiveProxy)
157
+ self.__track()
158
+ return #self.__target
159
+ end
160
+
161
+ return {
162
+ reactive = reactive,
163
+ isReactive = isReactive,
164
+ toRaw = toRaw,
165
+ }