diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af91488..912f2ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,11 +14,19 @@ jobs:
run_install: true
- uses: CompeyDev/setup-rokit@v0.1.2
+ - name: Setup Lute
+ run: lute setup --with-luaurc
+
- name: Check code quality
- run: sh bin/check.sh
+ run: lute run check
+
+ - name: Run ESLint and Prettier
+ run: |
+ pnpm eslint packages
+ pnpm prettier --check packages
- name: Run tests
- run: lune run test/spec.server.luau
+ run: lute run test
- name: Build models
- run: sh bin/build.sh
+ run: lute run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5bdee21..3bd512d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,8 +11,11 @@ jobs:
- uses: actions/checkout@v4
- uses: CompeyDev/setup-rokit@v0.1.2
+ - name: Setup Lute
+ run: lute setup --with-luaurc
+
- name: Build models
- run: sh bin/build.sh
+ run: lute run build
- name: Upload release assets
uses: AButler/upload-release-assets@v3.0
diff --git a/.gitignore b/.gitignore
index 52e0af1..d217e96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,7 @@
/.pesde
/build
/packages/**/wally.lock
-/packages/**/LICENSE.md
+/packages/**/LICENSE*
/packages/**/README.md
/sourcemap.json
node_modules
diff --git a/.gitmodules b/.gitmodules
index e1cba9d..409278b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
-[submodule "test/suite"]
- path = test/suite
+[submodule "tests/test"]
+ path = tests/test
url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
diff --git a/.luaurc b/.luaurc
index 4c8043d..c48b0f2 100644
--- a/.luaurc
+++ b/.luaurc
@@ -1,6 +1,7 @@
{
"languageMode": "strict",
- "lint": {
- "LocalShadow": false
+ "aliases": {
+ "lute": "~/.lute/typedefs/1.0.0/lute",
+ "std": "~/.lute/typedefs/1.0.0/std"
}
}
diff --git a/.lute/build.luau b/.lute/build.luau
new file mode 100644
index 0000000..b708003
--- /dev/null
+++ b/.lute/build.luau
@@ -0,0 +1,6 @@
+local fs = require("@std/fs")
+local process = require("@lute/process")
+
+fs.createDirectory("build")
+
+process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" })
diff --git a/.lute/check.luau b/.lute/check.luau
new file mode 100644
index 0000000..b5ad31b
--- /dev/null
+++ b/.lute/check.luau
@@ -0,0 +1,33 @@
+local fs = require("@std/fs")
+local net = require("@std/net")
+local process = require("@lute/process")
+
+process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })
+
+fs.writeStringToFile(
+ "roblox.d.luau",
+ net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
+)
+
+local analyze = process.run({
+ "luau-lsp",
+ "analyze",
+ "--sourcemap=sourcemap.json",
+ "--ignore=**/node_modules/**",
+ "--base-luaurc=.luaurc",
+ "--definitions=roblox.d.luau",
+ "--flag:LuauSolverV2=true",
+ "packages",
+ "tests",
+ "benches",
+}, { stdio = "inherit" })
+
+fs.remove("roblox.d.luau")
+
+local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" })
+
+local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" })
+
+if not (analyze.ok and stylua.ok and eslint.ok) then
+ process.exit(1)
+end
diff --git a/.lute/test.luau b/.lute/test.luau
new file mode 100644
index 0000000..4abbbb9
--- /dev/null
+++ b/.lute/test.luau
@@ -0,0 +1 @@
+(require)("../tests/run.server")
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index b644580..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-public-hoist-pattern[]=*@rbxts*
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d10b611
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright 2024-present Littensy
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
deleted file mode 100644
index ae1bb3d..0000000
--- a/LICENSE.md
+++ /dev/null
@@ -1,7 +0,0 @@
-Copyright 2024 Littensy
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index 46e04d8..07d9641 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Charm
- Manage state with reactive signals
+ Fine-grained reactivity for Roblox
npm package →
@@ -14,25 +14,26 @@

[](https://www.npmjs.com/package/@rbxts/charm)
-[](LICENSE.md)
+[](LICENSE)
-**Charm** is a Roblox state management library inspired by the [reactive signals](https://preactjs.com/blog/introducing-signals/) from libraries like Preact and Solid. It's built on [alien-signals](https://github.com/stackblitz/alien-signals), modified for a wide range of use cases: from rendering user interfaces to handling game logic.
+Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates.
-**Charm works under a few core principles.**
+**Build game state from simple building blocks:**
-- Manage state with reactive signals: state containers that hold a value
-- React to state updates: reading a signal automatically subscribes to it
-- Combine multiple signals: derive new values from existing state that stay up-to-date
-- Fine-grained reactivity: make targeted updates in response to specific changes in the state
-- Data should be immutable: values are compared directly (`==`) to optimize change detection
+- Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value
+- React to state changes with [effects](#effectcallback): re-run code when signals update
+- Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals
-**Want to learn more about signals?**
+**Want to learn more about reactivity?**
+- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf
- https://preactjs.com/blog/introducing-signals
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
-- https://angular.dev/guide/signals
+- https://github.com/roblox/signals
+
+[Migrating from an older version of Charm?](#migration)
Table of Contents
@@ -42,25 +43,31 @@
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
- [`computed(getter)`](#computedgetter)
- [`effect(callback)`](#effectcallback)
- - [`untracked(callback)`](#untrackedcallback)
- - [`peek(callback)`](#peekcallback)
- - [`batch(callback)`](#batchcallback)
+ - [Nested effects](#nested-effects)
- [`effectScope(callback)`](#effectscopecallback)
- [`listen(getter, callback)`](#listengetter-callback)
- - [`subscribe(getter, callback)`](#subscribegetter-callback)
+ - [Getter functions](#getter-functions)
- [`observe(getter, callback)`](#observegetter-callback)
- - [`mapped(getter, mapper)`](#mappedgetter-mapper)
+ - [`subscribe(getter, callback)`](#subscribegetter-callback)
+ - [`untracked(callback)`](#untrackedcallback)
+ - [`batch(callback)`](#batchcallback)
+ - [`mapped(getter, transform)`](#mappedgetter-transform)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- - [`recursive()`](#recursive)
- - [`trigger(callback)`](#triggercallback)
- - [`flags`](#flags)
+ - [`trigger(callback)`](#triggercallback)
+ - [`flags`](#flags)
- [Client-Server Sync](#client-server-sync)
- [Installation](#installation-1)
- [Quick Start](#quick-start)
- [Server API](#config)
- [Client API](#clientaddsignalssetters)
- [Sync Caveats](#sync-caveats)
+- [Deep Reactivity](#deep-reactivity)
+ - [Installation](#installation-2)
+ - [`reactive(initialValue)`](#reactiveinitialvalue)
+ - [Mutation vs. update function](#mutation-vs-update-function)
+ - [`toRaw(value)`](#torawvalue)
+ - [`isReactive(value)`](#isreactivevalue)
- [Migration](#migration)
- [Examples](#examples)
@@ -69,19 +76,19 @@
## At a Glance
```luau
-local getTodos, setTodos = signal({} :: { [number]: string })
+local getTodos, setTodos = signal({} :: { string })
local getQuery, setQuery = signal("")
-observe(getTodos, function(todo, index)
+observe(getTodos, function(todo: string, index: number)
local instance = Instance.new("TextLabel")
- local getText = computed(function(prevText)
- return getTodos()[index] or prevText
+ local getText = computed(function()
+ return getTodos()[index] or ""
end)
effect(function()
instance.Text = getText()
- instance.Visible = getText():match(getQuery()) ~= nil
+ instance.Visible = string.match(getText(), getQuery()) ~= nil
end)
instance.LayoutOrder = index
@@ -94,30 +101,31 @@ observe(getTodos, function(todo, index)
end)
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
-setQuery("^Buy")
+setQuery("Buy")
```
Explain code
```luau
-local getTodos, setTodos = signal({} :: { [number]: string })
+-- Declare state for a todo list and a search query
+local getTodos, setTodos = signal({} :: { string })
local getQuery, setQuery = signal("")
-- Create a text label when an item is added to the list
observe(getTodos, function(todo, index)
local instance = Instance.new("TextLabel")
- -- Create a computed signal that stores the current text, or the previous
- -- value if the item was removed
- local getText = computed(function(prevText)
- return getTodos()[index] or prevText
+ -- Create a memoized function that only re-runs effects when the todo list
+ -- updates or the function returns new text
+ local getText = computed(function()
+ return getTodos()[index] or ""
end)
- -- Set properties when the text or query updates
+ -- Update instance properties when the text or query updates
effect(function()
instance.Text = getText()
- instance.Visible = getText():match(getQuery()) ~= nil
+ instance.Visible = string.match(getText(), getQuery()) ~= nil
end)
instance.LayoutOrder = index
@@ -132,8 +140,8 @@ end)
-- Add items to the todo list
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
--- Filter for items starting with "Buy"
-setQuery("^Buy")
+-- Filter for items containing "Buy"
+setQuery("Buy")
```
@@ -147,6 +155,7 @@ pnpm add @rbxts/charm
```
```toml
+# wally.toml
[dependencies]
Charm = "littensy/charm@VERSION"
```
@@ -162,11 +171,11 @@ Signals are the core of reactivity in Charm. The `signal` function creates a rea
```luau
local getCounter, setCounter = signal(0)
-print(getCounter()) -- 0
setCounter(1)
setCounter(function(count)
return count + 1
end)
+print(getCounter()) -- 2
```
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.
@@ -184,42 +193,7 @@ setMax(-1) -- 2
```
> [!NOTE]
-> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a combined getter and setter.
-
----
-
-### `computed(getter)`
-
-The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value.
-
-The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value.
-
-```luau
-local getName, setName = signal("John")
-local getSurname, setSurname = signal("Doe")
-local getFullName = computed(function()
- return `{getName()} {getSurname()}`
-end)
-
-print(getFullName()) -- "John Doe"
-setName("Jane")
-print(getFullName()) -- "Jane Doe"
-```
-
-The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state:
-
-```luau
-local getCounter, setCounter = signal(10)
-local getMax = computed(function(prevMax)
- return math.max(getCounter(), prevMax or 0)
-end)
-
-print(getMax()) -- 10
-setCounter(5)
-print(getMax()) -- 10
-```
-
-Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call.
+> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter.
---
@@ -230,10 +204,9 @@ Effects are fundamental to reactivity, allowing you to react to signal updates.
```luau
local getCounter, setCounter = signal(0)
--- Count is 0
effect(function()
print(`Count is {getCounter()}`)
-end)
+end) -- Count is 0
setCounter(1) -- Count is 1
```
@@ -254,20 +227,29 @@ setCounter(1) -- Cleanup 0
dispose() -- Cleanup 1
```
-Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`.
+### Nested Effects
+
+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:
```luau
-local getCounter, setCounter = signal(0)
+local getPrintCount, setPrintCount = signal(true)
+local getCount, setCount = signal(1)
effect(function()
- print(`Outer: {getCounter()}`)
- effect(function()
- print(`Inner: {getcounter()}`)
- end)
-end)
+ if getPrintCount() then
+ -- This inner effect is created when getPrintCount() is true
+ effect(function()
+ print(`Count is {getCount()}`)
+ end)
+ end
+end) -- Count is 1
-setCounter(1) -- Outer: 1, Inner: 1
-setCounter(2) -- Outer: 2, Inner: 2
+setCount(2) -- Count is 2
+
+-- This re-runs the outer effect and cleans up old inner effects
+setPrintCount(false)
+
+setCount(3) -- No output
```
> [!NOTE]
@@ -275,86 +257,40 @@ setCounter(2) -- Outer: 2, Inner: 2
---
-### `untracked(callback)`
+### `computed(getter)`
-In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked.
-
-```luau
-local getTracked, setTracked = signal(0)
-local getUntracked, setUntracked = signal(0)
-
--- Tracked: 0, Untracked: 0
-effect(function()
- print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
-end)
-
-setTracked(1) -- Tracked: 1, Untracked: 0
-setUntracked(1) -- No output
-setTracked(2) -- Tracked: 2, Untracked: 1
-```
-
-Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect:
-
-```luau
-local stopEffect
-local stopScope = effectScope(function()
- untracked(function()
- stopEffect = effect(function()
- return function()
- print("Cleaned up untracked effect")
- end
- end)
- end)
-end)
-
-stopScope() -- No output, the scope did not track the effect
-stopEffect() -- Cleaned up untracked effect
-```
-
----
-
-### `peek(callback)`
-
-Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked.
-
-```luau
-local getCounter, setCounter = signal(0)
-
--- Count is 0
-local disposeOuter = effect(function()
- print(`Count is {peek(getCounter)}`)
-end)
-
-setCounter(1) -- No output; count was accessed in peek()
-```
-
----
-
-### `batch(callback)`
-
-Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
+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.
```luau
local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe")
-
-effect(function()
- print(`Full name: {getName()} {getSurname()}`)
+local getFullName = computed(function()
+ return `{getName()} {getSurname()}`
end)
--- Combines both writes into a single update.
--- Once the callback completes, outputs "Full name: Foo Bar"
-batch(function()
- setName("Foo")
- setSurname("Bar")
+print(getFullName()) -- "John Doe"
+setName("Jane")
+print(getFullName()) -- "Jane Doe"
+```
+
+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:
+
+```luau
+local getCounter, setCounter = signal(10)
+local getMax = computed(function(prevMax)
+ return math.max(getCounter(), prevMax or 0)
end)
+
+print(getMax()) -- 10
+setCounter(5)
+print(getMax()) -- 10
```
---
### `effectScope(callback)`
-Scopes allow you to dispose multiple effects at once. The `effectScope` function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes.
+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.
```luau
local getCounter, setCounter = signal(0)
@@ -386,15 +322,77 @@ The callback also receives the previous value, or `nil` when running for the fir
```luau
local getCounter, setCounter = signal(0)
--- Count is 0 (was nil)
listen(getCounter, function(count, prevCount)
print(`Count is {count} (was {prevCount})`)
-end)
+end) -- Count is 0 (was nil)
setCounter(1) -- Count is 1 (was 0)
```
-Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed.
+Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up.
+
+### Getter functions
+
+In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked:
+
+```luau
+local getCounter, setCounter = signal(0)
+
+local function floorCounter()
+ return math.floor(getCounter())
+end
+
+listen(floorCounter, function(count, prevCount)
+ print(`Floor of count is {count} (was {prevCount})`)
+end) -- Floor of count is 0 (was nil)
+
+setCounter(0.5) -- Doesn't print anything, floor is still 0
+setCounter(1) -- Floor of count is 1 (was 0)
+```
+
+---
+
+### `observe(getter, callback)`
+
+[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.
+
+```luau
+local getItems, setItems = signal({ a = 0, b = 0 })
+
+observe(getItems, function(value, key)
+ print(`Added {key}`)
+ return function()
+ print(`Removed {key}`)
+ end
+end) -- Added a, Added b
+
+setItems({ a = 0, c = 0 }) -- Removed b, Added c
+```
+
+The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:
+
+```luau
+local getItems, setItems = signal({})
+
+local dispose = observe(getItems, function(value, key)
+ local getValue = computed(function(prevValue)
+ return getItems()[key] or prevValue
+ end)
+
+ effect(function()
+ local value = getValue()
+ print(`Set {key} = {value}`)
+ return function()
+ print(`Cleanup {key} = {value}`)
+ end
+ end)
+end)
+
+setItems({ a = 0, b = 0 }) -- Set a = 0, Set b = 0
+setItems({ a = 1, b = 0 }) -- Cleanup a = 0, Set a = 1
+setItems({ a = 1 }) -- Cleanup b = 0
+dispose() -- Cleanup a = 1
+```
---
@@ -413,59 +411,72 @@ end)
setCounter(1) -- Count is 1 (was 0)
```
-Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed.
-
---
-### `observe(getter, callback)`
+### `untracked(callback)`
-[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.
+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.
```luau
-local getItems, setItems = signal({ a = 0, b = 0 })
+local getTracked, setTracked = signal(0)
+local getUntracked, setUntracked = signal(0)
--- Added a, Added b
-observe(getItems, function(value, key)
- print(`Added {key}`)
- return function()
- print(`Removed {key}`)
- end
-end)
+effect(function()
+ print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
+end) -- Tracked: 0, Untracked: 0
-setItems({ a = 0, c = 0 }) -- Removed b, Added c
+setTracked(1) -- Tracked: 1, Untracked: 0
+setUntracked(1) -- No output
+setTracked(2) -- Tracked: 2, Untracked: 1
```
-The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:
+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:
```luau
-local getItems, setItems = signal({ a = 0, b = 0 })
-
-local dispose = observe(getItems, function(value, key)
- local getValue = computed(function(prevValue)
- return getItems()[key] or prevValue
- end)
-
- effect(function()
- local value = getValue()
- print(`{key} = {value}`)
- return function()
- print(`Cleanup {key} = {value}`)
- end
+local stopEffect
+local stopScope = effectScope(function()
+ untracked(function()
+ stopEffect = effect(function()
+ return function()
+ print("Cleaned up untracked effect")
+ end
+ end)
end)
end)
-setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1
-setItems({ a = 1 }) -- Cleanup b = 0
-dispose() -- Cleanup a = 1
+stopScope() -- No output, the scope did not track the effect
+stopEffect() -- Cleaned up untracked effect
```
---
-### `mapped(getter, mapper)`
+### `batch(callback)`
-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.
+Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
-The first value returned by the mapper is used as the new value:
+```luau
+local getName, setName = signal("John")
+local getSurname, setSurname = signal("Doe")
+
+effect(function()
+ print(`Full name: {getName()} {getSurname()}`)
+end)
+
+-- Combines both writes into a single update.
+-- Once the callback completes, outputs "Full name: Foo Bar"
+batch(function()
+ setName("Foo")
+ setSurname("Bar")
+end)
+```
+
+---
+
+### `mapped(getter, transform)`
+
+The `mapped` function iterates over every key in a table and uses the transform function 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, `transform` is called for that key and its current value.
+
+The first value returned by the transform function is used as the new value:
```luau
local getList, setList = signal({ "a", "b", "c" })
@@ -477,7 +488,7 @@ end)
print(getUppercase()) -- { "A", "B", "C" }
```
-If the mapper returns two values, the second value is used as the new key:
+If the transform function returns two values, the second value is used as the new key:
```luau
local getList, setList = signal({ "a", "b", "c" })
@@ -495,7 +506,7 @@ print(getSwapped()) -- { a = 1, b = 2, c = 3 }
The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect.
-If there is no active effect, a warning is logged, unless `failSilently` is set to `true`.
+Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope.
```luau
local dispose = effectScope(function()
@@ -538,26 +549,6 @@ max(-1) -- 1
---
-### `recursive()`
-
-By design, Charm's reactive system [does not allow recursion](https://github.com/stackblitz/alien-signals/issues/90#issuecomment-3489711800) in effects or computed signals. In case you need to opt out of recursion checks for an effect, you can call `recursive()` at the top of the effect callback:
-
-```luau
-local getCounter, setCounter = signal(0)
-
-effect(function()
- recursive()
- print(getCounter())
- setCounter(function(count)
- return math.min(count + 1, 3)
- end)
-end)
-
--- Output: 0, 1, 2, 3
-```
-
----
-
### `trigger(callback)`
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:
@@ -605,13 +596,13 @@ print(total()) -- 2
Charm exposes the following global flags to customize behavior:
-| Flag | Default | Description |
-| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
-| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. |
-| 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. |
+| Flag | Default | Description |
+| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
+| frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. |
+| 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. |
-The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower.
+The `strict` and `frozen` flags are automatically enabled in Roblox Studio.
---
@@ -632,68 +623,99 @@ CharmSync = "littensy/charm-sync@VERSION"
### Quick Start
-Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals:
+Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory:
```luau
--- nameStore
-local getName, setName = signal("John")
-local getSurname, setSurname = signal("Doe")
+-- store.luau
+
+local getStatus, setStatus = signal("intermission")
+local timerAtom = atom(20)
+local getInventories, setInventories = signal({} :: { [Username]: Inventory })
return {
- getName = getName,
- setName = setName,
- getSurname = getSurname,
- setSurname = setSurname,
+ getStatus = getStatus,
+ setStatus = setStatus,
+ timerAtom = timerAtom,
+ getInventories = getInventories,
+ setInventories = setInventories,
}
```
-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.
+#### Server setup
-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.
+When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
+
+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:
```luau
-local function onPlayerAdded(player: Player)
- -- Add signal getters, computed signals, or atoms
+-- sync.server.luau
+
+playerReadyEvent.OnServerEvent:Connect(function(player)
+ -- Sync signal getters, computed signals, atoms, or reactive proxies
server.addSignalsToClient(player, {
- name = nameStore.getName,
- surname = nameStore.getSurname,
+ status = store.getStatus,
+ timer = store.timerAtom,
+ -- Signals unique to a player should also have a key unique to
+ -- that player, to avoid overwriting other synced signals
+ [`inventory-{player.UserId}`] = computed(function()
+ return store.getInventories()[player.Name]
+ end),
})
-end
-
-for _, player in Players:GetPlayers() do
- onPlayerAdded(player)
-end
-
-Players.PlayerAdded:Connect(onPlayerAdded)
+end)
Players.PlayerRemoving:Connect(function(player)
server.removeClient(player)
end)
server.connect(function(player, updates)
- -- Customize how you send state updates to clients
+ -- Customize how you replicate state updates to clients
syncEvent:FireClient(player, updates)
end)
+
+-- Changes now get replicated to clients
+store.setStatus("started")
+store.timerAtom(30)
```
-> [!NOTE]
-> 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.
+> [!WARNING]
+> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player.
+>
+> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning.
+
+#### Client setup
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.
After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
```luau
--- Add signal setters or atoms
+-- sync.client.luau
+
+-- The server is replicating a signal containing the client's
+-- inventory, so this signal will receive those updates
+local getInventory, setInventory = signal({} :: Inventory)
+
+-- Add signal setters, atoms, or reactive proxies
client.addSignals({
- name = nameStore.setName,
- surname = nameStore.setSurname,
+ status = store.setStatus,
+ timer = store.timerAtom,
+ -- Signals unique to a player should also have a key unique to
+ -- that player, to avoid name collisions
+ [`inventory-{Players.LocalPlayer.UserId}`] = setInventory,
})
-- Update client signals when receiving updates from the server
syncEvent.OnClientEvent:Connect(function(updates)
client.patch(updates)
end)
+
+-- Notify the server that the client is ready to receive updates
+playerReadyEvent:FireServer()
+
+-- The signals will now update to match the server's state
+subscribe(getInventory, function(inventory)
+ print("My inventory is", table.concat(inventory))
+end)
```
---
@@ -706,36 +728,49 @@ A configuration table that customizes the behavior of Charm Sync on the server.
| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| 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. |
-| 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.). |
-| 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). |
+| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
+| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). |
---
### `server.addSignalsToClient(client, getters)`
-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.
+The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed.
-You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple on the same client to subscribe to new signals.
+You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals.
```luau
-Players.PlayerAdded:Connect(function(player)
+playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, {
- name = nameStore.getName,
- surname = nameStore.getSurname,
+ status = store.getStatus,
+ timer = store.timerAtom,
})
end)
```
-You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player:
+You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal:
```luau
-server.addSignalsToClient(player, {
- [`data-{player.UserId}`] = computed(function()
- return getPlayerData(player.UserId)
- end),
-})
+playerReadyEvent.OnServerEvent:Connect(function(player)
+ server.addSignalsToClient(player, {
+ -- 🟢 Good: Player-specific signals are synced with a unique key
+ [`playerData-{player.UserId}`] = computed(function()
+ return clientStore.getPlayerData(player.UserId)
+ end),
+
+ -- 🔴 Bad: Syncing different signals with the same keys does not work
+ playerData = computed(function()
+ return clientStore.getPlayerData(player.UserId)
+ end),
+ })
+end)
```
+> [!NOTE]
+> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player.
+>
+> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning.
+
---
### `server.removeSignalsFromClient(client, ...keys)`
@@ -744,10 +779,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke
```luau
server.addSignalsToClient(player, {
- surname = nameStore.getSurname,
+ status = store.getStatus,
+ timer = store.timerAtom,
})
-server.removeSignalsFromClient(player, "surname")
+server.removeSignalsFromClient(player, "status")
```
---
@@ -757,7 +793,7 @@ server.removeSignalsFromClient(player, "surname")
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.
```luau
-Players.PlayerAdded:Connect(function(player)
+playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, signals)
end)
@@ -804,12 +840,15 @@ server.flush()
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.
-You can pass either writable signals or atoms to this function:
+You can pass either writable signals, atoms, or [reactive objects](#reactiveinitialvalue) to this function:
```luau
client.addSignals({
- name = nameStore.setName,
- surname = nameStore.setSurname,
+ status = store.setStatus,
+ timer = store.timerAtom,
+ -- Signals unique to a player should also have a key unique to
+ -- that player, to avoid name collisions
+ [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
})
```
@@ -821,10 +860,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta
```luau
client.addSignals({
- name = nameStore.setName,
+ status = store.setStatus,
+ timer = store.timerAtom,
})
-client.removeSignals("name")
+client.removeSignals("status")
```
---
@@ -837,8 +877,9 @@ You should call `patch` when receiving updates from the server from a remote eve
```luau
client.addSignals({
- name = nameStore.setName,
- surname = nameStore.setSurname,
+ status = store.setStatus,
+ timer = store.timerAtom,
+ [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
})
syncEvent.OnClientEvent:Connect(function(updates)
@@ -848,35 +889,144 @@ end)
---
+### `signalToAtom(getter, setter)`
+
+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`.
+
+```luau
+local sharedState = {
+ status = signalToAtom(store.getStatus, store.setStatus),
+ inventories = signalToAtom(store.getInventories, store.setInventories),
+}
+
+client.addSignals(sharedState)
+server.addSignalsToClient(client, sharedState)
+```
+
+---
+
### Sync Caveats
-Charm Sync will only send clients the differences between the current state and the previously-synced state, which is a practice called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`.
+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`.
-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" }`.
+But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`.
-This means nilable values can be represented as `None`, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload.
+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.
+
+---
+
+## Deep Reactivity
+
+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.
+
+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.
+
+You can opt-in to deep reactivity with the Deep Charm library:
+
+### Installation
+
+> [!WARNING]
+> Deep Charm is an experimental library that is not fully documented, and the API is subject to breaking changes.
+
+```sh
+npm install @rbxts/deep-charm
+yarn add @rbxts/deep-charm
+pnpm add @rbxts/deep-charm
+```
+
+```toml
+[dependencies]
+DeepCharm = "littensy/deep-charm@VERSION"
+```
+
+---
+
+### `reactive(initialValue)`
+
+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.
+
+```luau
+local users, updateUsers = reactive({
+ { name = "John", surname = "Doe" },
+})
+
+effect(function()
+ print(`{users[1].name} {users[1].surname}`)
+end) -- Output: John Doe
+
+updateUsers(function(raw)
+ raw[1].name = "Jane"
+ raw[1].surname = "Smith"
+ table.insert(raw, { name = "Steve", surname = "Doe" })
+end) -- Output: Jane Smith
+
+-- You can also mutate the reactive proxy directly:
+users[1].name = "John" -- John Smith
+```
+
+### Mutation vs. update function
+
+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()`.
+
+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.
+
+---
+
+### `toRaw(value)`
+
+If you need to access the raw table through the reactive proxy, use the `toRaw()` function:
+
+```luau
+local raw = {}
+local proxy = reactive(raw)
+
+print(toRaw(proxy) == raw) -- true
+```
+
+Calling `toRaw()` on a proxy also subscribes to its changes. This allows you to use proxies with `observe()` and other Charm APIs that accept a getter function:
+
+```luau
+local proxy, updateProxy = reactive({})
+
+observe(function()
+ return toRaw(proxy)
+end, function(value, key)
+ print(`Value {value} added at key {key}`)
+end)
+
+updateProxy(function(state)
+ table.insert(state, "foo")
+end) -- Value foo added at key 1
+```
+
+---
+
+### `isReactive(value)`
+
+The `isReactive()` function returns `true` if the given value is a reactive proxy by checking its metatable.
+
+```lua
+local raw = {}
+local proxy = reactive(raw)
+
+print(isReactive(proxy)) -- true
+print(isReactive(raw)) -- false
+```
---
## Migration
-Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version.
+Charm v0.11 introduces several breaking changes, so this section should help you migrate from an older version.
-
-Terminology changes
-
-- Signal: A state container with one function to get the state, and another to update it
-- Atom: A signal with the getter and setter combined into one function
-- Effect: Main way to react to state changes
-- `subscribe(getter, callback)`: Creates an effect that only subscribes to signals accessed in the getter
-
-
+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.
**What to look out for:**
1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like:
+ - `peek()` was changed to `untracked()` for parity with other state managers
- The second arguments of `atom()` changed from an `options` table to an equality function
- - Removed the second argument of `computed()` (you can do your own equality checks now)
+ - Removed the second argument of `computed()` (you can do your own equality checks since the computed callback now receives the previous value)
- Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`)
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:
@@ -884,46 +1034,39 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
- Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`.
- [Read the updated docs for syncing state →](#client-server-sync)
-3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior:
+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:
- `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error
- `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
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.
- - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`.
+ - 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()`.
- Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback).
- - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`.
+ - 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`.
-> [!NOTE]
-> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`.
->
-> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI.
-
-5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior.
- - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback.
-
-6. 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)!
- - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit
- - Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately
- - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once
- - Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect
- - Added [`trigger()`](#triggercallback) to trigger updates for table mutations
- - The [`computed()`](#computedgetter) callback now gets called with the previous computed value
- - Optimized `computed()` to use lazy evaluation instead of eager updates
- - Optimized `mapped()` to only map values that changed in the original table
+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)!
+ - [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit
+ - [`listen()`](#listengetter-callback): create a subscription that also runs once immediately
+ - [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
+ - [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope
+ - [`trigger()`](#triggercallback): trigger updates for table mutations
---
## Examples
-Check out this simple [example project →](https://github.com/littensy/charm-example)
+- https://github.com/littensy/fishing-minigame: Fisch clone using Charm for server and client state
### React Counter
```luau
-local getCounter, setCounter = signal(0)
+local Charm = require("@packages/charm")
+local ReactCharm = require("@packages/react-charm")
+local React = require("@packages/react")
+
+local getCounter, setCounter = Charm.signal(0)
local function Counter()
- local count = useSignalState(getCounter)
+ local count = ReactCharm.useSignalState(getCounter)
return React.createElement("TextButton", {
[React.Event.Activated] = function()
@@ -938,10 +1081,16 @@ end
### Vide Counter
```luau
-local getCounter, setCounter = signal(0)
+local Charm = require("@packages/charm")
+local VideCharm = require("@packages/vide-charm")
+local Vide = require("@packages/vide")
+
+local create = Vide.create
+
+local getCounter, setCounter = Charm.signal(0)
local function Counter()
- local count = useSignalState(getCounter)
+ local count = VideCharm.useSignalState(getCounter)
return create "TextButton" {
Activated = function()
@@ -960,11 +1109,11 @@ end
---
-Charm is released under the MIT License.
+Charm is released under the MIT License.
-[](LICENSE.md)
+[](LICENSE)
diff --git a/benches/bench.luau b/benches/bench.luau
index 9c24c6e..ffb839d 100644
--- a/benches/bench.luau
+++ b/benches/bench.luau
@@ -20,8 +20,8 @@ local function run(name: string, test: () -> ())
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
- for name, elapsed in times do
- table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
+ for name, duration in times do
+ table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`)
end
print(table.concat(results, " | "))
diff --git a/benches/mapped.luau b/benches/mapped.luau
index 6b6d5dc..695b7f5 100644
--- a/benches/mapped.luau
+++ b/benches/mapped.luau
@@ -3,75 +3,80 @@ local bench = require("./bench")
local computed = Charm.computed
local mapped = Charm.mapped
-local setActiveSub = Charm.setActiveSub
local signal = Charm.signal
-type Entry = { number }
+type Key = number
+type Value = number
local count = 512
-local initialState: { Entry } = table.create(count)
+local initialState: { [Key]: Value } = table.create(count, 0)
-for i = 1, count do
- initialState[i] = table.create(64, i)
+local function expensiveOperation()
+ for _ = 1, 100 do
+ continue
+ end
end
-local function mapper(value: Entry, key: number): (Entry, number)
- return table.clone(value), key + 1
-end
-
-local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
+local function simpleMapped(
+ source: () -> { [Key]: Value },
+ mapper: (Value, Key) -> (Value, Key?)
+): () -> { [Key]: Value }
return computed(function()
local state = source()
local nextState = {}
- for index, value in state do
- local currentSub = setActiveSub(nil)
- local success, valueOut, keyOut = pcall(mapper, value, index)
- setActiveSub(currentSub)
-
- if success then
- nextState[keyOut] = valueOut
- end
+ for keyIn, valueIn in state do
+ local valueOut, keyOut = mapper(valueIn, keyIn)
+ nextState[if keyOut == nil then keyIn else keyOut] = valueOut
end
- return state
+ return nextState
end)
end
-local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
+local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
return function(state)
local nextState = table.clone(state)
- for index, value in state do
- if math.random() < percent then
- nextState[index] = table.clone(value)
+ for key in state do
+ if math.random() < updateChance then
+ nextState[key] += 1
end
end
return nextState
end
end
-for _, percent in { 0.1, 0.5, 1 } do
- bench.run(`mapped {percent}`, function()
+for _, updateChance in { 0.1, 0.5, 1 } do
+ local function mapper(value: Value, key: Key): (Value, Key?)
+ expensiveOperation()
+ return value + 1, key + 1
+ end
+
+ bench.run(`mapped {updateChance}`, function()
local src, setSrc = signal(initialState)
local out = mapped(src, mapper)
bench.profilebegin("init")
out()
bench.profileend()
- setSrc(updatePercentage(percent))
+
+ setSrc(updateRandomly(updateChance))
+
bench.profilebegin("update")
out()
bench.profileend()
end)
- bench.run(`control {percent}`, function()
+ bench.run(`control {updateChance}`, function()
local src, setSrc = signal(initialState)
local out = simpleMapped(src, mapper)
bench.profilebegin("init")
out()
bench.profileend()
- setSrc(updatePercentage(percent))
+
+ setSrc(updateRandomly(updateChance))
+
bench.profilebegin("update")
out()
bench.profileend()
diff --git a/benches/multi.luau b/benches/multi.luau
new file mode 100644
index 0000000..6c8235c
--- /dev/null
+++ b/benches/multi.luau
@@ -0,0 +1,188 @@
+local Charm = require("../packages/charm/src")
+local bench = require("./bench")
+
+type Atom = Charm.Atom
+
+local signal = Charm.atom
+local computed = Charm.computed
+local effect = Charm.effect
+
+local function busy(): number
+ local a = 0
+ for i = 1, 100 do
+ a += 1
+ end
+ return a
+end
+
+do
+ local head = signal(0)
+ local computed1 = computed(function()
+ return head()
+ end)
+ local computed2 = computed(function()
+ computed1()
+ return 0
+ end)
+ local computed3 = computed(function()
+ busy() -- heavy computation
+ return computed2() + 1
+ end)
+ local computed4 = computed(function()
+ return computed3() + 2
+ end)
+ local computed5 = computed(function()
+ return computed4() + 3
+ end)
+ effect(function()
+ computed5()
+ busy() -- heavy side effect
+ end)
+
+ bench.run("avoidablePropagation", function()
+ head(1)
+ assert(computed5() == 6)
+ for i = 0, 999 do
+ head(i)
+ end
+ assert(computed5() == 6)
+ end)
+end
+
+do
+ local head = signal(0)
+ local double = computed(function()
+ return head() * 2
+ end)
+ local inverse = computed(function()
+ return -head()
+ end)
+ local current = computed(function()
+ local result = 0
+ for i = 0, 19 do
+ result += if head() % 2 == 1 then double() else inverse()
+ end
+ return result
+ end)
+
+ local callCounter = 0
+ effect(function()
+ current()
+ callCounter += 1
+ end)
+
+ bench.run("unstable", function()
+ head(1)
+ assert(current() == 40)
+ callCounter = 0
+ for i = 0, 99 do
+ head(i)
+ end
+ assert(callCounter == 100)
+ end)
+end
+
+do
+ local head = signal(0)
+ local double = computed(function()
+ return head() * 2
+ end)
+ local inverse = computed(function()
+ return -head()
+ end)
+ local current = computed(function()
+ local result = 0
+ for i = 0, 19 do
+ result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse()
+ end
+ return result
+ end)
+
+ local callCounter = 0
+ effect(function()
+ current()
+ callCounter += 1
+ end)
+
+ bench.run("unstable 50%", function()
+ head(1)
+ assert(current() == 10)
+ callCounter = 0
+ for i = 0, 99 do
+ head(i)
+ end
+ assert(callCounter == 100)
+ end)
+end
+
+do
+ local head = signal(0)
+ local last: () -> number = head
+ local callCounter = 0
+
+ for i = 0, 49 do
+ local current = computed(function()
+ return head() + i
+ end)
+ local current2 = computed(function()
+ return current() + 1
+ end)
+ effect(function()
+ current2()
+ callCounter += 1
+ end)
+ last = current2
+ end
+
+ bench.run("broadPropagation", function()
+ head(1)
+ callCounter = 0
+ for i = 0, 49 do
+ head(i)
+ assert(last() == i + 50)
+ end
+ assert(callCounter == 50 * 50)
+ end)
+end
+
+do
+ local heads = {}
+ for index = 0, 99 do
+ heads[index] = signal(0)
+ end
+
+ local mux = computed(function()
+ local result = {}
+ for index = 0, 99 do
+ result[index] = heads[index]()
+ end
+ return result
+ end)
+
+ local splited = {}
+ for index = 0, 99 do
+ local x = computed(function()
+ return mux()[index]
+ end)
+ splited[index] = computed(function()
+ return x() + 1
+ end)
+ end
+
+ for _, x in splited do
+ effect(function()
+ x()
+ end)
+ end
+
+ bench.run("mux", function()
+ for i = 0, 9 do
+ heads[i](i)
+ assert(splited[i]() == i + 1)
+ end
+ for i = 0, 9 do
+ heads[i](i * 2)
+ assert(splited[i]() == i * 2 + 1)
+ end
+ end)
+end
diff --git a/benches/patch.bench.luau b/benches/patch.bench.luau
deleted file mode 100644
index dc8f918..0000000
--- a/benches/patch.bench.luau
+++ /dev/null
@@ -1,54 +0,0 @@
-local ReplicatedStorage = game:GetService("ReplicatedStorage")
-
-local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src)
-
-local patch = CharmSync.patch
-
-local USERS = 1000
-
-local function generate()
- local users = {}
-
- for user = 1, USERS do
- users[user] = {
- coins = math.random(1, 2),
- gems = math.random(1, 2),
- }
- end
-
- return { users = users }
-end
-
-local function update(state)
- state = table.clone(state)
- state.users = table.clone(state.users)
-
- for user = 1, USERS do
- local rng = math.random()
-
- if rng < 1 / 3 then
- continue
- elseif rng < 2 / 3 then
- state.users[user] = nil
- else
- state.users[user] = {
- coins = math.random(1, 2),
- gems = math.random(1, 2),
- }
- end
- end
-
- return state
-end
-
-return {
- ParameterGenerator = function()
- local state = generate()
- return state, update(state)
- end,
- Functions = {
- diff = function(_, a, b)
- patch.diff(a, b)
- end,
- },
-}
diff --git a/benches/reactive.luau b/benches/reactive.luau
new file mode 100644
index 0000000..c272651
--- /dev/null
+++ b/benches/reactive.luau
@@ -0,0 +1,88 @@
+local Charm = require("../packages/charm/src")
+local DeepCharm = require("../packages/deep-charm/src")
+local bench = require("./bench")
+
+local effect = Charm.effect
+local reactive = DeepCharm.reactive
+
+do
+ local value = reactive({ a = 1 })
+ bench.run("track reactive property", function()
+ effect(function()
+ local _ = value.a
+ end)
+ end)
+end
+
+do
+ local value = reactive({ a = 1 })
+ bench.run("write reactive property", function()
+ value.a += 1
+ end)
+end
+
+for _, amount in { 10, 100, 1000 } do
+ print("---")
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ bench.run(`track for loop, {amount} elements`, function()
+ local sum = 0
+ effect(function()
+ for i = 1, #array do
+ sum += array[i]
+ end
+ end)
+ end)
+ end
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ bench.run(`track iteration, {amount} elements`, function()
+ local sum = 0
+ effect(function()
+ for _, x in array do
+ sum += x
+ end
+ end)
+ end)
+ end
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ effect(function()
+ local sum = 0
+ for _, x in array do
+ sum += x
+ end
+ end)
+
+ bench.run(`track index mutation (1st only), {amount} elements`, function()
+ array[1] += 1
+ end)
+ end
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ effect(function()
+ local sum = 0
+ for _, x in array do
+ sum += x
+ end
+ end)
+
+ bench.run(`track index mutation (all), {amount} elements`, function()
+ for i = 1, #array do
+ array[i] += 1
+ end
+ end)
+ end
+end
diff --git a/bin/build.sh b/bin/build.sh
deleted file mode 100644
index f680537..0000000
--- a/bin/build.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash
-
-mkdir build
-rojo build build.project.json -o build/charm.rbxm
diff --git a/bin/check.sh b/bin/check.sh
deleted file mode 100644
index 091c4a4..0000000
--- a/bin/check.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
-
-rojo sourcemap -o sourcemap.json
-
-luau-lsp analyze \
- --defs=bin/roblox.d.luau \
- --flag:LuauFixIndexerSubtypingOrdering=true \
- --flag:LuauInstantiateInSubtyping=true \
- --sourcemap=sourcemap.json \
- --ignore="**/node_modules/**" \
- packages test benches
-
-selene packages test benches
-
-stylua --check packages test benches
-
-pnpm eslint packages
-
-rm bin/roblox.d.luau
diff --git a/build.project.json b/build.project.json
index 8141b86..53d32b4 100644
--- a/build.project.json
+++ b/build.project.json
@@ -4,6 +4,7 @@
"$className": "Folder",
"Charm": { "$path": "packages/charm/src" },
"CharmSync": { "$path": "packages/charm-sync/src" },
+ "DeepCharm": { "$path": "packages/deep-charm/src" },
"ReactCharm": { "$path": "packages/react-charm/src" },
"VideCharm": { "$path": "packages/vide-charm/src" }
}
diff --git a/default.project.json b/default.project.json
index 43a7d1a..85d8f55 100644
--- a/default.project.json
+++ b/default.project.json
@@ -19,16 +19,25 @@
},
"charm": {
"$className": "Folder",
- "src": { "$path": "packages/charm/src" }
+ "src": { "$path": "packages/charm/src" },
+ "test": { "$path": "packages/charm/test" }
+ },
+ "deep-charm": {
+ "$className": "Folder",
+ "src": { "$path": "packages/deep-charm/src" },
+ "test": { "$path": "packages/deep-charm/test" },
+ "Charm": { "$path": "packages/deep-charm/Charm.luau" }
},
"charm-sync": {
"$className": "Folder",
"src": { "$path": "packages/charm-sync/src" },
+ "test": { "$path": "packages/charm-sync/test" },
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
},
"react-charm": {
"$className": "Folder",
"src": { "$path": "packages/react-charm/src" },
+ "test": { "$path": "packages/react-charm/test" },
"React": { "$path": "packages/react-charm/React.luau" },
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
"Charm": { "$path": "packages/react-charm/Charm.luau" }
@@ -36,6 +45,7 @@
"vide-charm": {
"$className": "Folder",
"src": { "$path": "packages/vide-charm/src" },
+ "test": { "$path": "packages/vide-charm/test" },
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
}
@@ -43,8 +53,8 @@
"@rbxts-js": {
"$path": "node_modules/@rbxts-js"
},
- "test": {
- "$path": "test"
+ "tests": {
+ "$path": "tests"
}
}
}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..bfccfb4
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,20 @@
+const eslint = require("@eslint/js");
+const { defineConfig, globalIgnores } = require("eslint/config");
+const prettierRecommended = require("eslint-plugin-prettier/recommended");
+const roblox = require("eslint-plugin-roblox-ts");
+const tseslint = require("typescript-eslint");
+
+module.exports = defineConfig(
+ eslint.configs.recommended,
+ tseslint.configs.recommended,
+ roblox.configs.tsRecommendedCompat,
+ roblox.configs.recommended,
+ {
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ "roblox-ts/no-any": "off",
+ },
+ },
+ globalIgnores(["out/**", "eslint.config.js"]),
+ prettierRecommended,
+);
diff --git a/eslint.config.ts b/eslint.config.ts
deleted file mode 100644
index 21ece76..0000000
--- a/eslint.config.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import eslint from "@eslint/js";
-import { defineConfig, globalIgnores } from "eslint/config";
-import prettierRecommended from "eslint-plugin-prettier/recommended";
-import roblox from "eslint-plugin-roblox-ts";
-import tseslint from "typescript-eslint";
-
-export default defineConfig(
- eslint.configs.recommended,
- tseslint.configs.recommended,
- roblox.configs.tsRecommendedCompat,
- roblox.configs.recommended,
- {
- rules: {
- "@typescript-eslint/no-explicit-any": "off",
- "roblox-ts/no-any": "off",
- },
- },
- globalIgnores(["out/**"]),
- prettierRecommended,
-);
diff --git a/package.json b/package.json
index b9b8739..d0bf03d 100644
--- a/package.json
+++ b/package.json
@@ -1,30 +1,29 @@
{
"private": true,
"scripts": {
- "test": "lune run test/spec.server.luau",
- "build": "sh bin/build.sh",
- "check": "sh bin/check.sh",
+ "test": "lute run test",
+ "build": "lute run build",
+ "check": "lute run check",
"publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
},
- "packageManager": "pnpm@10.22.0",
+ "packageManager": "pnpm@12.8.1",
"devDependencies": {
- "@eslint/js": "^9.36.0",
+ "@eslint/js": "^9.39.4",
"@rbxts/compiler-types": "3.0.0-types.0",
- "@rbxts/react": "17.3.5-alpha.1",
- "@rbxts/react-roblox": "17.3.5-alpha.1",
- "@rbxts/types": "^1.0.883",
- "@rbxts/vide": "^0.5.7",
- "@typescript-eslint/eslint-plugin": "^8.44.1",
- "@typescript-eslint/parser": "^8.44.1",
- "eslint": "^9.36.0",
+ "@rbxts/react": "17.3.7-ts.1",
+ "@rbxts/react-roblox": "17.3.7-ts.1",
+ "@rbxts/types": "^1.0.913",
+ "@rbxts/vide": "^0.6.1",
+ "@typescript-eslint/eslint-plugin": "^8.58.0",
+ "@typescript-eslint/parser": "^8.58.0",
+ "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
- "eslint-plugin-prettier": "^5.5.4",
- "eslint-plugin-roblox-ts": "^1.2.1",
- "jiti": "^2.6.0",
- "prettier": "^3.6.2",
+ "eslint-plugin-prettier": "^5.5.5",
+ "eslint-plugin-roblox-ts": "^1.4.0",
+ "prettier": "^3.8.1",
"roblox-ts": "^3.0.0",
- "typescript": "^5.9.2",
- "typescript-eslint": "^8.44.1"
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.58.0"
}
}
diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau
index 9a55fe0..480f2d5 100644
--- a/packages/charm-sync/Charm.luau
+++ b/packages/charm-sync/Charm.luau
@@ -4,6 +4,6 @@ end
local Charm = require(script.Parent.Parent.charm.src)
-export type Atom = Charm.Atom
+export type Atom = Charm.Atom
return Charm
diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json
index d41be6c..f2c84a3 100644
--- a/packages/charm-sync/package.json
+++ b/packages/charm-sync/package.json
@@ -1,6 +1,6 @@
{
"name": "@rbxts/charm-sync",
- "version": "0.4.0-rc.1",
+ "version": "0.4.1",
"description": "Simple server-to-client sync for Charm",
"license": "MIT",
"main": "src/init.luau",
@@ -11,17 +11,17 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
- "!**/__tests__"
+ "!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
- "prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
+ "prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
- "@rbxts/charm": "workspace:*"
+ "@rbxts/charm": "workspace:^"
}
}
diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau
deleted file mode 100644
index 5f8513c..0000000
--- a/packages/charm-sync/src/__tests__/spec.luau
+++ /dev/null
@@ -1,27 +0,0 @@
-local suite = require("../../../../test/suite")
-
-suite.describe("client", function()
- require("./client.spec")
-end)
-
-suite.describe("e2e", function()
- require("./e2e.spec")
-end)
-
-suite.describe("utils", function()
- require("./utils.spec")
-end)
-
-suite.describe("patch", function()
- require("./patch.spec")
-end)
-
-suite.describe("server", function()
- require("./server.spec")
-end)
-
-suite.describe("validate", function()
- require("./validate.spec")
-end)
-
-return {}
diff --git a/packages/charm-sync/src/__tests__/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau
deleted file mode 100644
index 1f107fd..0000000
--- a/packages/charm-sync/src/__tests__/validate.spec.luau
+++ /dev/null
@@ -1,24 +0,0 @@
-local suite = require("../../../../test/suite")
-local validate = require("../validate")
-
-local test = suite.test
-
-test("validates inputs", function()
- local cases: { [string]: { value: any, expected: any } } = {
- array = { value = { 1, 2, 3 }, expected = true },
- arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
- arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
- mixedTable = { value = { [1] = "foo", a = true }, expected = false },
- }
-
- for key, case in cases do
- local success, result: any = pcall(validate, case.value)
- if case.expected and not success then
- error(`Expected {key} to be valid, threw {result}`)
- elseif not case.expected and success then
- error(`Expected {key} to be invalid, but it was valid`)
- end
- end
-end)
-
-return {}
diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau
index 3427726..973a983 100644
--- a/packages/charm-sync/src/client.luau
+++ b/packages/charm-sync/src/client.luau
@@ -3,66 +3,98 @@ local patch = require("./patch")
local types = require("./types")
type SyncPayload = types.SyncPayload
+type ReactiveProxy = types.ReactiveProxy
-type Setter = (update: ((T) -> T) | T) -> ()
+local trackedSignals: { [string]: (any) -> any } = {}
+local trackedReactives: { [string]: ReactiveProxy } = {}
-local trackedSetters: { [string]: Setter } = {}
+local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
+ local target = reactive.__target
+ for key, value in source do
+ target[key] = value
+ end
+ Charm.trigger(reactive.__track)
+end
---- Registers a map of setters to the state with the corresponding keys on the
---- server. When the server sends updates, these setters will be called with
---- the new values.
----
---- Atoms, signals converted to atoms, and signal setter functions are allowed.
---- Note that `server.addSignalsToClient` still requires some way to get the
---- state.
----
---- @param setters A map of setter functions to sync with the server.
-local function addSignals(setters: { [string]: Setter })
- for key, setter in setters do
- trackedSetters[key] = setter
+--[[
+ Registers a map of setters to the state with the corresponding keys on the
+ server. When the server sends updates, these setters will be called with
+ the new values.
+
+ Atoms, signals converted to atoms, and signal setter functions are allowed.
+ Note that `server.addSignalsToClient` still requires some way to get the
+ state.
+
+ @param signals A map of setter functions to sync with the server.
+]]
+local function addSignals(signals: { [string]: ((any) -> any) | {} })
+ for key, signal in signals do
+ if type(signal) == "function" then
+ trackedSignals[key] = signal
+ else
+ trackedReactives[key] = (signal :: any) :: ReactiveProxy
+ end
end
end
---- Unregisters from server state updates for the given keys. The signals
---- will retain their current values, but will no longer receive updates
---- from the server.
+--[[
+ Unregisters from server state updates for the given keys. The signals
+ will retain their current values, but will no longer receive updates
+ from the server.
+]]
local function removeSignals(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
- trackedSetters[key] = nil
+ trackedSignals[key] = nil
+ trackedReactives[key] = nil
end
end
---- Unsubscribes from all server state updates.
+--[[
+ Unsubscribes from all server state updates.
+]]
local function removeAllSignals()
- table.clear(trackedSetters)
+ table.clear(trackedSignals)
+ table.clear(trackedReactives)
end
local function applyInitPayload(data: { [string]: any })
for key, value in data do
- local setter = trackedSetters[key]
- if setter then
- setter(value)
+ if trackedSignals[key] then
+ trackedSignals[key](value)
+ elseif trackedReactives[key] then
+ shallowMergeReactive(trackedReactives[key], value)
+ else
+ warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end
end
end
local function applyPatchPayload(data: { [string]: any })
- for key, value in data do
- local setter = trackedSetters[key]
- if setter then
- setter(function(current)
- return patch.apply(current, value)
+ for key, diffs in data do
+ local trackedSignal = trackedSignals[key]
+ local trackedReactive = trackedReactives[key]
+
+ if trackedSignal then
+ trackedSignal(function(value)
+ return patch.apply(value, diffs)
end)
+ elseif trackedReactive then
+ patch.applyMutable(trackedReactive.__target, diffs)
+ Charm.trigger(trackedReactive.__track)
+ else
+ warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end
end
end
---- Merges the server's state patches into the client's state, either
---- initializing it with the full state or merging a partial patch.
----
---- @param payloads The state updates received from the server.
-local function patch(payloads: { SyncPayload })
+--[[
+ Merges the server's state patches into the client's state, either
+ initializing it with the full state or merging a partial patch.
+
+ @param payloads The state updates received from the server.
+]]
+local function applyPatches(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
Charm.batch(applyInitPayload, payload.data)
@@ -76,5 +108,5 @@ return {
addSignals = addSignals,
removeSignals = removeSignals,
removeAllSignals = removeAllSignals,
- patch = patch,
+ patch = applyPatches,
}
diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau
index 9b528fc..c3ce27c 100644
--- a/packages/charm-sync/src/config.luau
+++ b/packages/charm-sync/src/config.luau
@@ -1,20 +1,31 @@
return {
- --- The interval at which to send patches to the client, in seconds.
- --- Defaults to `0` (patches are sent up to once per frame). Set to a
- --- negative value to disable automatic syncing.
+ --[[
+ The interval at which to send patches to the client, in seconds.
+ Defaults to `0` (patches are sent up to once per frame). Set to a
+ negative value to disable automatic syncing.
+ ]]
interval = 0,
- --- Whether the history of state changes since the client's last update
- --- should be preserved. This is useful for values that change multiple
- --- times per frame, where each individual change is important. Defaults
- --- to `false`.
+ --[[
+ Whether the history of state changes since the client's last update
+ should be preserved. This is useful for values that change multiple
+ times per frame, where each individual change is important. Defaults
+ to `false` for performance reasons.
+ ]]
preserveHistory = false,
- --- When `true`, Charm will apply validation and serialize unsafe arrays
- --- to address remote event argument limitations. Defaults to `true`.
- --- This option should be disabled if your network library uses a custom
- --- serialization method (i.e. Zap, ByteNet) to prevent interference.
+ --[[
+ When `true`, Charm will modify sparse arrays to prevent data loss from
+ remote event limitations. When an unsafe array is detected, numeric
+ indices will be converted to string keys so that the array can be sent
+ safely and then converted back to an array on the receiving end.
+
+ This option should be disabled if your network library uses a custom
+ serialization method (i.e. Zap, ByteNet) to prevent interference.
+ ]]
fixArrays = true,
- --- When `true`, synced state containing unsafe sparse arrays or mixed
- --- tables will throw an error. Only checked in strict mode and if
- --- `fixArrays` is enabled. Defaults to `true`.
+ --[[
+ When `true`, synced state containing unsafe sparse arrays or mixed
+ tables will emit a warning. Only checked in strict mode and if
+ `fixArrays` is enabled. Defaults to `true`.
+ ]]
validatePatches = true,
}
diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts
index 69381b6..bb40eba 100644
--- a/packages/charm-sync/src/index.d.ts
+++ b/packages/charm-sync/src/index.d.ts
@@ -11,8 +11,11 @@ export interface None {
export type MaybeNone = undefined extends T ? None : never;
-type GetterMap = Record any>;
-type SetterMap = Record>;
+type ReactiveObject = { [key: Key]: any };
+
+type ServerSignalMap = Record any) | ReactiveObject>;
+
+type ClientSignalMap = Record | ReactiveObject>;
/**
* Infers the type of the return values produced by a map of functions.
@@ -55,7 +58,7 @@ export type SyncPatch = MaybeNone =
+export type SyncPayload =
| { type: "init"; data: StateOfMap }
| { type: "patch"; data: SyncPatch, FixArrays> };
@@ -75,14 +78,17 @@ export const config: {
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple
* times per frame, where each individual change is important. Defaults
- * to `false`.
+ * to `false` for performance reasons.
*
* @server
*/
preserveHistory: boolean;
/**
- * When `true`, Charm will apply validation and serialize unsafe arrays
- * to address remote event argument limitations. Defaults to `true`.
+ * When `true`, Charm will modify sparse arrays to prevent data loss from
+ * remote event limitations. When an unsafe array is detected, numeric
+ * indices will be converted to string keys so that the array can be sent
+ * safely and then converted back to an array on the receiving end.
+ *
* This option should be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet) to prevent interference.
*
@@ -91,7 +97,7 @@ export const config: {
fixArrays: boolean;
/**
* When `true`, synced state containing unsafe sparse arrays or mixed
- * tables will throw an error. Only checked in strict mode and if
+ * tables will emit a warning. Only checked in strict mode and if
* `fixArrays` is enabled. Defaults to `true`.
*
* @server
@@ -112,16 +118,18 @@ export namespace client {
* Note that `server.addSignalsToClient` still requires some way to get the
* state.
*
- * @param setters A map of setter functions to sync with the server.
+ * @param signals A map of setter functions to sync with the server.
*/
- export function addSignals(setters: Setters): void;
+ export function addSignals(signals: Signals): void;
/**
* Unregisters from server state updates for the given keys. The signals
* will retain their current values, but will no longer receive updates
* from the server.
*/
- export function removeSignals(...keys: (keyof Setters)[]): void;
+ export function removeSignals(
+ ...keys: (keyof Signals)[]
+ ): void;
/**
* Unsubscribes from all server state updates.
@@ -134,7 +142,7 @@ export namespace client {
*
* @param payloads The state updates received from the server.
*/
- export function patch(
+ export function patch(
payloads: SyncPayload[],
): void;
}
@@ -152,9 +160,12 @@ export namespace server {
* that `client.addSignals` still requires some way to set the state.
*
* @param client The client receiving state updates.
- * @param getters A map of getter functions to sync with the client.
+ * @param signals A map of getter functions to sync with the client.
*/
- export function addSignalsToClient(client: Player, getters: Getters): void;
+ export function addSignalsToClient(
+ client: Player,
+ signals: Signals,
+ ): void;
/**
* Unsubscribes a client from receiving all state updates. To only unsubscribe
@@ -170,9 +181,9 @@ export namespace server {
* @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from.
*/
- export function removeSignalsFromClient(
+ export function removeSignalsFromClient(
client: Player,
- ...keys: (keyof Getters)[]
+ ...keys: (keyof Signals)[]
): void;
/**
@@ -182,7 +193,7 @@ export namespace server {
*
* @param onSync Called when sending patches to a client.
*/
- export function connect(
+ export function connect(
onSync: (client: Player, payloads: SyncPayload[]) => void,
): void;
@@ -234,7 +245,7 @@ export namespace patch {
* @param statePatch The patches to apply.
* @returns The new state with the patch applied.
*/
- export function apply(
+ export function applyImmutable(
currentState: State,
statePatch: SyncPatch,
): State;
@@ -276,5 +287,6 @@ export function isNone(value: unknown): value is None;
* @param getter A function that returns the current value of the signal.
* @param setter A function that sets the value of the signal.
* @returns An atom that can either get or set the signal's value.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
*/
export function signalToAtom(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom;
diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau
index f56a118..d6b940f 100644
--- a/packages/charm-sync/src/patch.luau
+++ b/packages/charm-sync/src/patch.luau
@@ -7,35 +7,39 @@ local flags = Charm.flags
export type None = types.None
---- A special symbol that denotes the absence of a value. Used to represent
---- deleted values in patches.
local None: None = { __none = "__none" }
---- Checks whether a value is `None`. If `true`, the value is scheduled to be
---- removed from the state when the patch is applied.
----
---- @param value The value to check.
---- @return `true` if the value is `None`, otherwise `false`.
+--[[
+ Checks whether a value is `None`. If `true`, the value is scheduled to be
+ removed from the state when the patch is applied.
+
+ @param value The value to check.
+ @return `true` if the value is `None`, otherwise `false`.
+]]
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
end
---- Converts `nil` values to the special `None` symbol. This is useful when
---- preparing data for serialization, since `nil` can represent both the absence
---- of a value and the removal of a value in patches.
+--[[
+ Converts `nil` values to the special `None` symbol. This is useful when
+ preparing data for serialization, since `nil` can represent both the absence
+ of a value and the removal of a value in patches.
+]]
local function nilToNone(value: T?): T | None
return if value == nil then None else value
end
---- JSON serialization can drop the last values of a sparse array. For example,
---- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
---- serialization of sparse arrays can be resolved by converting them to
---- dictionaries with string keys.
----
---- The `apply` function will convert these dictionaries back into arrays as
---- long as the target array is not sparse. This function ensures that the diff
---- function produces a patch that can be correctly applied.
-local function stringifySparseArray(object: { any }): { [string | number]: any }
+--[[
+ JSON serialization can drop the last values of a sparse array. For example,
+ `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
+ serialization of sparse arrays can be resolved by converting them to
+ dictionaries with string keys.
+
+ The `patch` functions will convert these dictionaries back into arrays as
+ long as the target array is not sparse. This function ensures that the diff
+ function produces a patch that can be correctly applied.
+]]
+local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
@@ -52,68 +56,78 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
return dictionary
end
---- Compares two states and returns a patch that can be applied to the current
---- state to produce the next state.
----
---- @param currentState The current state.
---- @param nextState The next state.
---- @return A patch that can be applied to the current state.
-local function diff(currentState: any, nextState: any): any
- if currentState == nextState then
+local function diff(oldState: any, newState: any): any
+ if oldState == newState then
return nil
- elseif nextState == nil then
+ elseif newState == nil then
return None
- elseif type(currentState) ~= "table" or type(nextState) ~= "table" then
- return nextState
+ elseif type(oldState) ~= "table" or type(newState) ~= "table" then
+ return newState
end
- local patches: { [any]: any } = table.clone(nextState)
+ local diffs = table.clone(newState)
- for key, previous in currentState do
- patches[key] = diff(previous, nextState[key])
+ for key, oldValue in oldState do
+ diffs[key] = diff(oldValue, newState[key])
+ end
+
+ if next(diffs) == nil then
+ return nil
end
if config.fixArrays then
-- Convert sparse array patches that could be misserialized
- if currentState[1] ~= nil or nextState[1] ~= nil then
- patches = stringifySparseArray(patches)
+ if oldState[1] ~= nil or newState[1] ~= nil then
+ diffs = stringifySparseArray(diffs)
end
-
if flags.strict and config.validatePatches then
- validate(patches)
+ validate(diffs)
end
end
- return patches
+ return diffs
end
---- Applies a patch to a state and returns the resulting value.
----
---- @param currentState The current state.
---- @param statePatch The patches to apply.
---- @return The new state with the patch applied.
-local function apply(currentState: any, statePatch: any): any
- if isNone(statePatch) then
+local function apply(oldState: any, diffs: any): any
+ if isNone(diffs) then
return nil
- elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then
- return statePatch
+ elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
+ return diffs
end
- local nextState = table.clone(currentState)
- local stateIsArray = currentState[1] ~= nil
+ local newState = table.clone(oldState)
+ local stateIsArray = oldState[1] ~= nil
- for key, value in statePatch do
+ for key, value in diffs do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
-
- nextState[key] = apply(nextState[key], value)
+ newState[key] = apply(newState[key], value)
end
- return nextState
+ return newState
+end
+
+local function applyMutable(targetState: any, diffs: any): any
+ if isNone(diffs) then
+ return nil
+ elseif type(targetState) ~= "table" or type(diffs) ~= "table" then
+ return diffs
+ end
+
+ local targetIsArray = targetState[1] ~= nil
+
+ for key, value in diffs do
+ if targetIsArray and type(key) == "string" then
+ key = tonumber(key) or key
+ end
+ targetState[key] = applyMutable(targetState[key], value)
+ end
+
+ return targetState
end
return {
@@ -121,4 +135,5 @@ return {
nilToNone = nilToNone,
diff = diff,
apply = apply,
+ applyMutable = applyMutable,
}
diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau
index 8979e0c..6485e18 100644
--- a/packages/charm-sync/src/server.luau
+++ b/packages/charm-sync/src/server.luau
@@ -2,6 +2,7 @@ local Charm = require("../Charm")
local config = require("./config")
local patch = require("./patch")
local types = require("./types")
+local utils = require("./utils")
type SyncPayload = types.SyncPayload
@@ -9,10 +10,11 @@ type SyncStatus = number
type ClientKeys = { [string]: SyncStatus }
-type GetterState = {
- getter: () -> T,
+type TrackedState = {
+ identifier: unknown,
currentValue: T,
lastSyncedValue: T,
+ isReactive: boolean,
dispose: () -> (),
}
@@ -25,50 +27,81 @@ type StateUpdate = {
local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1
-local trackedGetters: { [string]: GetterState } = {}
+local trackedStates: { [string]: TrackedState } = {}
local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection?
-local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
+local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
-local function startTracking(key: string, getter: () -> T)
- if trackedGetters[key] then
- -- Ignore duplicates, but warn against name collisions
- if trackedGetters[key].getter ~= getter then
+--[[
+ Manually converts a reactive proxy to its raw representation to potentially
+ support custom implementations of reactive proxies.
+]]
+local function toRaw(value: T): T
+ local proxy = value :: types.ReactiveProxy
+ if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
+ proxy.__track()
+ return toRaw(proxy.__target)
+ end
+ return value
+end
+
+local function trackSignal(key: string, getter: () -> T, identifier: unknown)
+ if trackedStates[key] then
+ -- Warn against name collisions with conflicting signals or proxies
+ if trackedStates[key].identifier ~= identifier then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
return
end
- local value = getter()
- local trackedState = {
- getter = getter,
- currentValue = value,
- lastSyncedValue = value,
- }
+ local trackedState: TrackedState
+ local initialValue = getter()
+ local rawInitialValue = toRaw(initialValue)
+ local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
+
+ local dispose = Charm.untracked(Charm.effect, function()
+ local newValue = getter()
+ local rawValue = toRaw(newValue)
+ local isReactive = newValue ~= rawValue
+
+ if not trackedState then
+ return
+ end
- trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
-- When preserving history, we need to keep track of previous values
-- so we can create granular patches for each change.
if config.preserveHistory then
+ local prevSnapshot = snapshot
+ snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
table.insert(globalUpdates, {
key = key,
- newValue = newValue,
- oldValue = oldValue,
+ newValue = snapshot,
+ oldValue = prevSnapshot,
})
end
- trackedState.currentValue = newValue
+
+ trackedState.currentValue = rawValue
+ trackedState.isReactive = isReactive
end)
- trackedGetters[key] = trackedState
+ trackedState = {
+ identifier = identifier,
+ currentValue = rawInitialValue,
+ lastSyncedValue = snapshot,
+ isReactive = initialValue ~= rawInitialValue,
+ dispose = dispose,
+ }
+
+ trackedStates[key] = trackedState
end
local function stopTracking(key: string)
- local trackedState = trackedGetters[key]
+ local trackedState = trackedStates[key]
if trackedState then
trackedState.dispose()
- trackedGetters[key] = nil
+ trackedStates[key] = nil
end
end
@@ -81,7 +114,7 @@ local function purgeUntracked()
end
end
- for key in trackedGetters do
+ for key in trackedStates do
if not active[key] then
stopTracking(key)
end
@@ -113,7 +146,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
for index, update in globalUpdates do
local patchData = globalPatches[index]
- if not patchData then
+ if patchData == nil then
continue
end
@@ -121,25 +154,27 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local status = clientKeys[key]
if status == LISTENING_FOR_CHANGES then
- table.insert(payloads, {
+ local payload: SyncPayload = {
type = "patch",
data = { [key] = patchData },
- })
+ }
+ table.insert(payloads, payload)
elseif status == PENDING_INITIAL_STATE then
- table.insert(payloads, {
+ local payload: SyncPayload = {
type = "init",
data = { [key] = update.newValue },
- })
+ }
+ table.insert(payloads, payload)
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
-- If a pending signal was never updated, we still need to send its initial
-- state. This can happen if a signal was registered after its last change.
- local initData: { [string]: any } = {}
+ local initData = {}
for key, status in clientKeys do
- local trackedState = trackedGetters[key]
+ local trackedState = trackedStates[key]
if status == PENDING_INITIAL_STATE and trackedState then
initData[key] = trackedState.currentValue
@@ -154,20 +189,24 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
return payloads
end
-local function diffGlobalState(): any
+local function diffGlobalState(): { [string]: any }?
local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {}
- for key, trackedState in trackedGetters do
- currentGlobalState[key] = trackedState.currentValue
+ for key, trackedState in trackedStates do
+ local currentValue = if trackedState.isReactive
+ then utils.deepCopy(trackedState.currentValue)
+ else trackedState.currentValue
+
+ currentGlobalState[key] = currentValue
previousGlobalState[key] = trackedState.lastSyncedValue
- trackedState.lastSyncedValue = trackedState.currentValue
+ trackedState.lastSyncedValue = currentValue
end
return patch.diff(previousGlobalState, currentGlobalState)
end
-local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
+local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
@@ -181,9 +220,9 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
-- Create patches for initialized signals that changed, and add signals
-- that are still pending initial state to the init payload.
for key, status in clientKeys do
- local trackedState = trackedGetters[key]
+ local trackedState = trackedStates[key]
- if status == LISTENING_FOR_CHANGES then
+ if status == LISTENING_FOR_CHANGES and globalPatch then
patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue
@@ -192,55 +231,97 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
end
if next(initData) ~= nil then
- table.insert(payloads, { type = "init", data = initData })
+ local payload: SyncPayload = {
+ type = "init",
+ data = initData,
+ }
+ table.insert(payloads, payload)
end
if next(patchData) ~= nil then
- table.insert(payloads, { type = "patch", data = patchData })
+ local payload: SyncPayload = {
+ type = "patch",
+ data = patchData,
+ }
+ table.insert(payloads, payload)
end
return payloads
end
---- Subscribes a client to the given signals. When an update occurs, the client
---- will receive a partial state patch to merge into their local state. May be
---- called multiple times to subscribe to additional keys.
----
---- Atoms, signals converted to atoms, and signal getter functions are allowed.
---- Note that `client.addSignals` still requires some way to set the state.
----
---- @param client The client receiving state updates.
---- @param getters A map of getter functions to sync with the client.
-local function addSignalsToClient(client: any, getters: { [string]: () -> any })
+local function startInterval(callback: () -> ())
+ if not game then
+ return
+ end
+
+ if connection then
+ connection:Disconnect()
+ end
+
+ local timestamp = os.clock()
+
+ connection = game:GetService("RunService").Heartbeat:Connect(function()
+ if config.interval >= 0 and os.clock() >= timestamp + config.interval then
+ timestamp = os.clock()
+ debug.profilebegin("CharmSync")
+ callback()
+ debug.profileend()
+ end
+ end)
+end
+
+--[[
+ Subscribes a client to the given signals. When an update occurs, the client
+ will receive a partial state patch to merge into their local state. May be
+ called multiple times to subscribe to additional keys.
+
+ Atoms, signals converted to atoms, and signal getter functions are allowed.
+ Note that `client.addSignals` still requires some way to set the state.
+
+ @param client The client receiving state updates.
+ @param signals A map of getter functions to sync with the client.
+]]
+local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
if not clients[client] then
clients[client] = {}
end
local clientKeys = clients[client]
- for key, getter in getters do
- startTracking(key, if type(getter) == "function" then getter else getter.get)
+ for key, signal in signals do
+ if type(signal) == "function" then
+ trackSignal(key, signal, signal)
+ else
+ trackSignal(key, function()
+ return signal
+ end, signal)
+ end
if not clientKeys[key] then
- -- Mark the getter as pending initial state so we send its full
+ -- Mark the signal as pending initial state so we send its full
-- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE
end
end
end
---- Unsubscribes a client from receiving all state updates. To only unsubscribe
---- from specific keys, use `unsubscribeClientFrom` instead.
----
---- @param client The client receiving state updates.
+--[[
+ Unsubscribes a client from receiving all state updates. To only unsubscribe
+ from specific keys, use `unsubscribeClientFrom` instead.
+
+ @param client The client receiving state updates.
+]]
local function removeClient(client: any)
clients[client] = nil
+ purgeUntracked()
end
---- Unsubscribes a client from receiving updates for the given keys.
----
---- @param client The client receiving state updates.
---- @param keys The keys of the state to unsubscribe from.
+--[[
+ Unsubscribes a client from receiving updates for the given keys.
+
+ @param client The client receiving state updates.
+ @param keys The keys of the state to unsubscribe from.
+]]
local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client]
@@ -255,23 +336,27 @@ local function removeSignalsFromClient(client: any, ...: string)
if next(clientKeys) == nil then
removeClient(client)
+ else
+ purgeUntracked()
end
end
---- Immediately sends pending updates to all clients. Normally, updates are
---- sent at the interval specified in `config.interval`, but this function
---- can be used to force an immediate sync.
+--[[
+ Immediately sends pending updates to all clients. Normally, updates are
+ sent at the interval specified in `config.interval`, but this function
+ can be used to force an immediate sync.
+]]
local function flush()
if not syncCallback then
return
end
local granular = config.preserveHistory
- local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
+ local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
for client in clients do
local payloads = if granular
- then selectFromGlobalPatchList(client, globalPatch)
+ then selectFromGlobalPatchList(client, globalPatch :: { any })
else selectFromGlobalPatch(client, globalPatch)
if payloads and payloads[1] then
@@ -283,46 +368,25 @@ local function flush()
end
end
- purgeUntracked()
table.clear(globalUpdates)
end
---- Uses the callback to send state updates to clients that are subscribed
---- to state changes. Starts automatically sending updates at the interval
---- specified in `config.interval`.
----
---- @param onSync Called when sending patches to a client.
+--[[
+ Uses the callback to send state updates to clients that are subscribed
+ to state changes. Starts automatically sending updates at the interval
+ specified in `config.interval`.
+
+ @param onSync Called when sending patches to a client.
+]]
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
- if connection then
- connection:Disconnect()
- end
-
syncCallback = onSync
-
- if not game then
- return
- end
-
- local timer = 0
-
- connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
- if config.interval < 0 then
- return
- end
-
- timer += deltaTime
-
- if timer >= config.interval then
- timer = 0
- debug.profilebegin("CharmSync")
- flush()
- debug.profileend()
- end
- end)
+ startInterval(flush)
end
---- Stops syncing state updates to clients at the automatic interval.
---- Flushing can still be performed manually via `flush`.
+--[[
+ Stops syncing state updates to clients at the automatic interval.
+ Flushing can still be performed manually via `flush`.
+]]
local function disconnect()
if connection then
connection:Disconnect()
@@ -330,10 +394,12 @@ local function disconnect()
end
end
---- Unsubscribes all clients, stops tracking all state, and disconnects
---- from the automatic sync interval.
+--[[
+ Unsubscribes all clients, stops tracking all state, and disconnects
+ from the automatic sync interval.
+]]
local function reset()
- table.clear(trackedGetters)
+ table.clear(trackedStates)
table.clear(clients)
table.clear(globalUpdates)
disconnect()
diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau
index ac8ffb4..664ee61 100644
--- a/packages/charm-sync/src/types.luau
+++ b/packages/charm-sync/src/types.luau
@@ -1,5 +1,7 @@
---- A payload that can be sent from the server to the client to synchronize
---- the states of the client's atoms with the server.
+--[[
+ A payload that can be sent from the server to the client to synchronize
+ the states of the client's atoms with the server.
+]]
export type SyncPayload =
| {
type: "init",
@@ -10,8 +12,19 @@ export type SyncPayload =
data: { [string]: any },
}
---- A special type that represents a removed value in a patch. This is used to
---- differentiate between a value that was set to `nil` and an unchanged value.
+--[[
+ A special type that represents a removed value in a patch. This is used to
+ differentiate between a value that was set to `nil` and an unchanged value.
+]]
export type None = { __none: "__none" }
+export type ReactiveProxy = typeof(setmetatable(
+ {} :: {
+ __track: () -> (),
+ __target: T,
+ __signals: { [unknown]: () -> unknown },
+ },
+ {} :: { __index: T }
+))
+
return {}
diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau
index 6a7f7b0..4a49871 100644
--- a/packages/charm-sync/src/utils.luau
+++ b/packages/charm-sync/src/utils.luau
@@ -2,28 +2,22 @@ local Charm = require("../Charm")
type Atom = Charm.Atom
-type NestedTable = {
- [any]: NestedTable | any,
-}
+--[[
+ Flattens a nested table into a single table with slash-separated keys.
+ Useful for recursively collecting atoms returned by modules.
-type FlattenedTable = {
- [any]: any,
-}
+ @param input A nested table.
+ @return A flattened table with slash-separated keys.
+]]
+local function flatten(input: { [any]: any }): Flattened
+ local result = {}
---- Flattens a nested table into a single table with slash-separated keys.
---- Useful for recursively collecting atoms returned by modules.
----
---- @param input A nested table.
---- @return A flattened table with slash-separated keys.
-local function flatten(input: NestedTable): FlattenedTable
- local result: FlattenedTable = {}
-
- local function visit(node: NestedTable, path: string)
+ local function visit(node: { [any]: any }, path: string)
for key, value in node do
- local location = if path == "" then key else path .. "/" .. key
+ local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
if type(value) == "table" then
- visit(value :: NestedTable, location)
+ visit(value, location)
else
result[location] = value
end
@@ -32,23 +26,40 @@ local function flatten(input: NestedTable): FlattenedTable
visit(input, "")
- return result
+ return result :: any
end
---- Converts a signal getter and setter into an atom. This is useful for
---- syncing client signals with the server, as the sync system requires both
---- the getter and setter to update state.
----
---- @param getter A function that returns the current value of the signal.
---- @param setter A function that sets the value of the signal.
---- @return An atom that can either get or set the signal's value.
+--[[
+ Converts a signal getter and setter into an atom. This is useful for
+ syncing client signals with the server, as the sync system requires both
+ the getter and setter to update state.
+
+ @param getter A function that returns the current value of the signal.
+ @param setter A function that sets the value of the signal.
+ @return An atom that can either get or set the signal's value.
+]]
local function signalToAtom(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom
return function(...)
return if select("#", ...) == 0 then getter() else setter((...))
end
end
+--[[
+ Returns a deep copy of the given table. Skips tables with metatables to
+ prevent copying reactive proxies.
+]]
+local function deepCopy(object: T): T
+ local copy: any = table.clone(object :: any)
+ for key, value in copy do
+ if type(value) == "table" and getmetatable(value) == nil then
+ copy[key] = deepCopy(value)
+ end
+ end
+ return copy
+end
+
return {
flatten = flatten,
signalToAtom = signalToAtom,
+ deepCopy = deepCopy,
}
diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau
index f350bb7..dfba716 100644
--- a/packages/charm-sync/src/validate.luau
+++ b/packages/charm-sync/src/validate.luau
@@ -1,18 +1,18 @@
local function isTableUnsafe(object: { [any]: any })
- local key = next(object)
+ local firstKey = next(object)
- if key == nil then
+ if firstKey == nil then
return false
end
- local keyType = type(key)
+ local keyType = type(firstKey)
local maxKey = 0
-- Keys must all be the same type
for key in object do
if type(key) ~= keyType then
return true
- elseif keyType == "number" and key > maxKey then
+ elseif type(key) == "number" and key > maxKey then
maxKey = key
end
end
@@ -25,25 +25,21 @@ local function isTableUnsafe(object: { [any]: any })
return false
end
---- Throws an error if the value is a table that cannot be synced via
---- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
---- with remote argument limitations, such as sparse arrays or mixed tables.
----
---- @param value The table value.
---- @param key The key of the value.
---- @error Throws an error if the value cannot be synced.
+--[[
+ Emits a warning if the value is a table that cannot be synced via
+ RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
+ with remote argument limitations, such as sparse arrays or mixed tables.
+
+ @param value The table value.
+]]
local function validate(value: { [any]: any })
if isTableUnsafe(value) then
- -- Silence warnings during testing
- if game then
- warn("Cannot sync table:", value)
- end
- error(
- `Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
- .. "This can be for the following reasons:\n"
+ warn(
+ "Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
.. "1. The table is a sparse array with non-sequential indices\n"
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
- .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
+ .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
+ value
)
end
end
diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/test/client.test.luau
similarity index 76%
rename from packages/charm-sync/src/__tests__/client.spec.luau
rename to packages/charm-sync/test/client.test.luau
index 29a172e..20c41a8 100644
--- a/packages/charm-sync/src/__tests__/client.spec.luau
+++ b/packages/charm-sync/test/client.test.luau
@@ -1,11 +1,9 @@
-local Charm = require("../../Charm")
-local client = require("../client")
-local suite = require("../../../../test/suite")
+local Charm = require("../Charm")
+local DeepCharm = require("../../deep-charm/src")
+local client = require("../src/client")
+local test = require("../../../tests/test")
-local test = suite.test
-local afterEach = suite.afterEach
-
-afterEach(function()
+test.afterEach(function()
client.removeAllSignals()
end)
@@ -40,6 +38,31 @@ test("should merge patch", function()
assert(a().c == 2, "should not change c")
end)
+test("should merge patch (reactive)", function()
+ local a = DeepCharm.reactive({ b = 1, c = 2 })
+ local runs = 0
+
+ Charm.effect(function()
+ local _ = a.b + a.c
+ runs += 1
+ end)
+
+ client.addSignals({ a = a })
+ client.patch({
+ { type = "init", data = { a = { b = a.b, c = a.c } } },
+ })
+
+ assert(runs == 1, "should not trigger effect for redundant init")
+
+ client.patch({
+ { type = "patch", data = { a = { b = 3 } } },
+ })
+
+ assert(a.b == 3, "should set b to the new value")
+ assert(a.c == 2, "should not change c")
+ assert(runs == 2, "should trigger effect for patch")
+end)
+
test("should fix sparse array patches", function()
local a = Charm.atom({ 1 })
diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/test/e2e.test.luau
similarity index 61%
rename from packages/charm-sync/src/__tests__/e2e.spec.luau
rename to packages/charm-sync/test/e2e.test.luau
index 09c036c..62ca4a7 100644
--- a/packages/charm-sync/src/__tests__/e2e.spec.luau
+++ b/packages/charm-sync/test/e2e.test.luau
@@ -1,13 +1,11 @@
-local Charm = require("../../Charm")
-local client = require("../client")
-local config = require("../config")
-local server = require("../server")
-local suite = require("../../../../test/suite")
+local Charm = require("../Charm")
+local DeepCharm = require("../../deep-charm/src")
+local client = require("../src/client")
+local config = require("../src/config")
+local server = require("../src/server")
+local test = require("../../../tests/test")
-local afterEach = suite.afterEach
-local test = suite.test
-
-afterEach(function()
+test.afterEach(function()
server.reset()
client.removeAllSignals()
config.preserveHistory = false
@@ -16,9 +14,9 @@ end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
- local serverB = Charm.atom({ count = 0 })
+ local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
- local clientB = Charm.atom({ count = -1 })
+ local clientB = DeepCharm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
@@ -30,27 +28,32 @@ test("should replicate server state to client", function()
server.flush()
assert(clientA().count == 0, "Client atom A should be initialized to 0")
- assert(clientB().count == 0, "Client atom B should be initialized to 0")
+ assert(clientB.count == 0, "Client reactive B should be initialized to 0")
serverA({ count = 1 })
- serverB({ count = 1 })
+ serverB.count = 1
server.flush()
assert(clientA().count == 1, "Client atom A should be updated to 1")
- assert(clientB().count == 1, "Client atom B should be updated to 1")
+ assert(clientB.count == 1, "Client reactive B should be updated to 1")
+
+ local runs = -1
+ Charm.effect(function()
+ local _ = clientB.count
+ runs += 1
+ end)
- local prevClientB = clientB()
serverA({ count = 2 })
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
- assert(clientB() == prevClientB, "Client atom B should not be updated")
+ assert(runs == 0, "Client reactive B should not trigger update")
end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
- local serverB = Charm.atom({ count = 0 })
+ local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
- local clientB = Charm.atom({ count = -1 })
+ local clientB = DeepCharm.reactive({ count = -1 })
config.preserveHistory = true
@@ -67,30 +70,28 @@ test("should replicate historical server state to client", function()
Charm.effect(function()
local expected = table.remove(expectedA, 1)
- assert(expected == clientA().count, `Client atom A should be {expected}`)
+ assert(expected == clientA().count, `Client reactive A should be {expected}`)
end)
Charm.effect(function()
local expected = table.remove(expectedB, 1)
- assert(expected == clientB().count, `Client atom B should be {expected}`)
+ assert(expected == clientB.count, `Client reactive B should be {expected}`)
end)
server.flush()
serverA({ count = 1 })
serverA({ count = 2 })
- serverB({ count = 1 })
- serverB({ count = 2 })
+ serverB.count = 1
+ serverB.count = 2
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
- assert(clientB().count == 2, "Client atom B should be updated to 2")
+ assert(clientB.count == 2, "Client reactive B should be updated to 2")
- local prevClientB = clientB()
serverA({ count = 3 })
serverA({ count = 4 })
server.flush()
assert(clientA().count == 4, "Client atom A should be updated to 4")
- assert(clientB() == prevClientB, "Client atom B should not be updated")
end)
return {}
diff --git a/packages/charm-sync/test/init.luau b/packages/charm-sync/test/init.luau
new file mode 100644
index 0000000..111dc1d
--- /dev/null
+++ b/packages/charm-sync/test/init.luau
@@ -0,0 +1,23 @@
+local test = require("../../tests/test")
+
+test.describe("client", function()
+ require("@self/client.test")
+end)
+
+test.describe("e2e", function()
+ require("@self/e2e.test")
+end)
+
+test.describe("utils", function()
+ require("@self/utils.test")
+end)
+
+test.describe("patch", function()
+ require("@self/patch.test")
+end)
+
+test.describe("server", function()
+ require("@self/server.test")
+end)
+
+return {}
diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/test/patch.test.luau
similarity index 66%
rename from packages/charm-sync/src/__tests__/patch.spec.luau
rename to packages/charm-sync/test/patch.test.luau
index a62b456..076544e 100644
--- a/packages/charm-sync/src/__tests__/patch.spec.luau
+++ b/packages/charm-sync/test/patch.test.luau
@@ -1,9 +1,6 @@
-local config = require("../config")
-local patch = require("../patch")
-local suite = require("../../../../test/suite")
-
-local test = suite.test
-local afterEach = suite.afterEach
+local config = require("../src/config")
+local patch = require("../src/patch")
+local test = require("../../../tests/test")
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then
@@ -23,7 +20,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any }
end
end
-afterEach(function()
+test.afterEach(function()
config.fixArrays = true
config.validatePatches = true
end)
@@ -31,17 +28,17 @@ end)
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
- local patches = patch.diff(prevState, nextState)
+ local diffs = patch.diff(prevState, nextState)
- assert(patches ~= nil, "should return a patch")
- assert(patches.baz == "quux", "should return the changed value")
- assert(patches.foo == nil, "should omit unchanged values")
+ assert(diffs ~= nil, "should return a patch")
+ assert(diffs.baz == "quux", "should return the changed value")
+ assert(diffs.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
- local patches = { baz = "quux", qaz = "qux" }
- local applied = patch.apply(prevState, patches)
+ local diffs = { baz = "quux", qaz = "qux" }
+ local applied = patch.apply(prevState, diffs)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
@@ -49,11 +46,11 @@ test("applies a patch", function()
assert(applied.qaz == "qux", "should add the new value")
end)
-test("allows nested patches", function()
+test("allows nested diffs", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
- local patches = patch.diff(prevState, nextState)
- local applied = patch.apply(prevState, patches)
+ local diffs = patch.diff(prevState, nextState)
+ local applied = patch.apply(prevState, diffs)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
@@ -78,8 +75,8 @@ test("preserves array density after serdes", function()
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
- local patches = mockArraySerdes(patch.diff(prevState, nextState))
- local applied = patch.apply(prevState, patches)
+ local diffs = mockArraySerdes(patch.diff(prevState, nextState))
+ local applied = patch.apply(prevState, diffs)
for index in prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
@@ -96,10 +93,10 @@ test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
- local patches = mockArraySerdes(patch.diff(prevState, nextState))
- local applied = patch.apply(prevState, patches)
+ local diffs = mockArraySerdes(patch.diff(prevState, nextState))
+ local applied = patch.apply(prevState, diffs)
- -- Not enough information is available to determine whether 'patches'
+ -- Not enough information is available to determine whether 'diffs'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
@@ -119,20 +116,18 @@ test("optionally fixes sparse arrays", function()
end
end)
-test("optionally validates state", function()
- local success = pcall(function()
- config.validatePatches = true
- patch.diff({}, { [1] = 1, a = true })
- end)
+test("patches mutable state", function()
+ local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
+ patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
+ assert(targetState.baz == "quux", "should apply the changed value")
+ assert(targetState.foo == "bar", "should keep the unchanged value")
+ assert(targetState.qaz == "qux", "should add the new value")
+end)
- assert(not success, "should not allow mixed tables")
-
- success = pcall(function()
- config.validatePatches = false
- patch.diff({}, { [1] = 1, a = true })
- end)
-
- assert(success, "should skip validation if not requested")
+test("removes table from mutable state", function()
+ local targetState = { foo = { bar = "baz" } }
+ patch.applyMutable(targetState, { foo = { __none = "__none" } })
+ assert(targetState.foo == nil, "should remove the table")
end)
return {}
diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/test/server.test.luau
similarity index 60%
rename from packages/charm-sync/src/__tests__/server.spec.luau
rename to packages/charm-sync/test/server.test.luau
index f26b753..d24970c 100644
--- a/packages/charm-sync/src/__tests__/server.spec.luau
+++ b/packages/charm-sync/test/server.test.luau
@@ -1,15 +1,12 @@
-local Charm = require("../../Charm")
-local config = require("../config")
-local patch = require("../patch")
-local server = require("../server")
-local suite = require("../../../../test/suite")
+local Charm = require("../Charm")
+local DeepCharm = require("../../deep-charm/src")
+local config = require("../src/config")
+local patch = require("../src/patch")
+local server = require("../src/server")
+local test = require("../../../tests/test")
+local types = require("../src/types")
-local afterEach = suite.afterEach
-local beforeEach = suite.beforeEach
-local describe = suite.describe
-local test = suite.test
-
-afterEach(function()
+test.afterEach(function()
server.reset()
end)
@@ -18,7 +15,7 @@ test("should replicate state changes", function()
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
- local target, payloads
+ local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
@@ -45,7 +42,7 @@ test("should replicate partial state patches", function()
local client = {}
local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?)
- local target, payloads
+ local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...)
@@ -69,11 +66,60 @@ test("should replicate partial state patches", function()
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
end)
+test("should replicate partial state patches (reactive)", function()
+ local client = {}
+ local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
+ local target: unknown, payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a })
+ server.connect(function(...)
+ target, payloads = ...
+ end)
+
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "init", "Expected first payload to be init")
+ assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'")
+ assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
+ assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
+
+ a.x.y = 1
+ a.z = nil
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "patch", "Expected first payload to be patch")
+ assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'")
+ assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
+
+ a.z = 1
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "patch", "Expected first payload to be patch")
+ assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'")
+ assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
+end)
+
+test("should handle nested proxies", function()
+ local client = {}
+ local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
+ local payloads: {}? = nil
+
+ server.addSignalsToClient(client, { src = src })
+ server.connect(function(_, newPayloads)
+ payloads = newPayloads
+ end)
+ server.flush()
+
+ payloads = nil
+ server.flush()
+ assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
+end)
+
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
- local payloadsA, payloadsB
+ local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
@@ -89,8 +135,8 @@ test("should init new clients", function()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
- server.addSignalsToClient(clientB, { a = a })
a(2)
+ server.addSignalsToClient(clientB, { a = a })
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
@@ -102,7 +148,7 @@ test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
- local target, payloads
+ local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
@@ -127,7 +173,7 @@ test("should only replicate to synced clients", function()
local clientB = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
- local payloadsA, payloadsB
+ local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b })
@@ -166,24 +212,10 @@ test("should only replicate to synced clients", function()
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end)
-test("should validate state", function()
- local client = {}
- local a = Charm.atom({})
-
- server.addSignalsToClient(client, { a = a })
- server.connect(function() end)
-
- a({ [0.5] = true })
- assert(not pcall(server.flush), "Expected server.flush to fail")
-
- a({ [0.5] = true, valid = true })
- server.flush()
-end)
-
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
- local payloads
+ local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
@@ -197,12 +229,32 @@ test("should serialize state", function()
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
end)
-describe("historical", function()
- beforeEach(function()
+test("should not send unchanged state", function()
+ local client = {}
+ local a = Charm.atom(0)
+ local payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a })
+ server.connect(function(_, p)
+ payloads = p
+ end)
+
+ server.flush()
+ assert(payloads[1].type == "init", "Expected first payload to be init")
+ assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
+
+ payloads = {}
+ a(0)
+ server.flush()
+ assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
+end)
+
+test.describe("historical", function()
+ test.beforeEach(function()
config.preserveHistory = true
end)
- afterEach(function()
+ test.afterEach(function()
config.preserveHistory = false
end)
@@ -211,7 +263,7 @@ describe("historical", function()
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
- local target, payloads
+ local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
@@ -236,11 +288,41 @@ describe("historical", function()
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
+ test("should replicate granular state changes (reactive)", function()
+ local client = {}
+ local a = DeepCharm.reactive({ value = 0 })
+ local b = DeepCharm.reactive({ value = 0 })
+ local c = DeepCharm.reactive({ value = 0 })
+ local target: unknown, payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a, b = b, c = c })
+ server.connect(function(...)
+ target, payloads = ...
+ end)
+
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "init", "Expected first payload to be init")
+ assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
+ assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
+ assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
+
+ a.value = 1
+ a.value = 2
+ b.value = 2
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "patch", "Expected first payload to be patch")
+ assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
+ assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
+ assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
+ end)
+
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
- local payloadsA, payloadsB
+ local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
@@ -276,7 +358,7 @@ describe("historical", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
- local payloads
+ local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...)
@@ -301,6 +383,74 @@ describe("historical", function()
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
+
+ test("should not send unchanged state", function()
+ local client = {}
+ local a = Charm.atom(0)
+ local payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a })
+ server.connect(function(_, p)
+ payloads = p
+ end)
+
+ server.flush()
+ assert(payloads[1].type == "init", "Expected first payload to be init")
+ assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
+
+ payloads = {}
+ a(0)
+ server.flush()
+ assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
+ end)
+
+ test("should maintain patch list order for stale update", function()
+ local a = Charm.atom({ x = 1 })
+ local b = Charm.atom({ y = 1 })
+ local payloads
+
+ server.connect(function(_client, _payloads)
+ payloads = _payloads
+ end)
+
+ server.addSignalsToClient("client", { a = a, b = b })
+ server.flush()
+
+ payloads = nil
+ a({ x = 1 }) -- new table, same contents: diff is nil
+ b({ y = 2 })
+ server.flush()
+ assert(payloads, "Expected payloads to be sent")
+ assert(payloads[1].type == "patch", "Expected payload to be patch")
+ assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'")
+ end)
+end)
+
+test("addSignalsToClient should ignore tracking context", function()
+ local counter = Charm.atom(0)
+ local payloads: { any }?
+
+ server.connect(function(_client, _payloads)
+ payloads = _payloads
+ end)
+
+ local dispose = Charm.effectScope(function()
+ server.addSignalsToClient("PLAYER_ID", { counter = counter })
+ end)
+
+ server.flush()
+ assert(payloads, "Expected payloads to be sent")
+ assert(payloads[1].type == "init", "Expected first payload to be init")
+ assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'")
+
+ dispose()
+
+ payloads = nil
+ counter(1)
+ server.flush()
+ assert(payloads, "Expected payloads to be sent")
+ assert(payloads[1].type == "patch", "Expected first payload to be patch")
+ assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'")
end)
return {}
diff --git a/packages/charm-sync/src/__tests__/utils.spec.luau b/packages/charm-sync/test/utils.test.luau
similarity index 65%
rename from packages/charm-sync/src/__tests__/utils.spec.luau
rename to packages/charm-sync/test/utils.test.luau
index 47d2e39..1d50f55 100644
--- a/packages/charm-sync/src/__tests__/utils.spec.luau
+++ b/packages/charm-sync/test/utils.test.luau
@@ -1,16 +1,15 @@
-local Charm = require("../../Charm")
-local suite = require("../../../../test/suite")
-local utils = require("../utils")
-
-local test = suite.test
+local Charm = require("../Charm")
+local test = require("../../../tests/test")
+local utils = require("../src/utils")
test("should flatten functions", function()
local input = {
a = Charm.atom(0),
b = { c = Charm.atom(0) },
- d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
+ d = { e = { f = utils.signalToAtom<>(Charm.signal(0)) } },
}
- local result = utils.flatten(input)
+
+ local result = utils.flatten<<{ [string]: Charm.Atom }>>(input)
assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level")
diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml
index 6d6257c..589e7ed 100644
--- a/packages/charm-sync/wally.toml
+++ b/packages/charm-sync/wally.toml
@@ -1,15 +1,15 @@
[package]
name = "littensy/charm-sync"
-version = "0.4.0-rc.1"
+version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
- "src/__tests__",
+ "test",
"src/index.d.ts",
"package.json",
"Charm.luau",
]
[dependencies]
-Charm = "littensy/charm@0.11.0-rc.1"
+Charm = "littensy/charm@^0.11.0"
diff --git a/packages/charm/package.json b/packages/charm/package.json
index f4167c5..7ef2e29 100644
--- a/packages/charm/package.json
+++ b/packages/charm/package.json
@@ -1,6 +1,6 @@
{
"name": "@rbxts/charm",
- "version": "0.11.0-rc.1",
+ "version": "0.11.1",
"description": "Atomic state management based on alien-signals",
"license": "MIT",
"main": "src/init.luau",
@@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
- "!**/__tests__"
+ "!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
- "prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
+ "prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
}
}
diff --git a/packages/charm/src/__tests__/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau
deleted file mode 100644
index 3dd7284..0000000
--- a/packages/charm/src/__tests__/computed.spec.luau
+++ /dev/null
@@ -1,106 +0,0 @@
-local Charm = require("../")
-local suite = require("../../../../test/suite")
-
-local computed = Charm.computed
-local effect = Charm.effect
-local signal = Charm.signal
-local test = suite.test
-
-test("should correctly propagate changes through computed core", function()
- local src, setSrc = signal(0)
- local c1 = computed(function()
- return src() % 2
- end)
- local c2 = computed(function()
- return c1()
- end)
- local c3 = computed(function()
- return c2()
- end)
-
- c3()
- setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
- c2() -- c1 -> none, c2 -> none
- setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
-
- assert(c3() == 1, "bad final value")
-end)
-
-test("should propagate updated source value through chained computations", function()
- local src, setSrc = signal(0)
- local a = computed(function()
- return src()
- end)
- local b = computed(function()
- return a() % 2
- end)
- local c = computed(function()
- return src()
- end)
- local d = computed(function()
- return b() + c()
- end)
-
- assert(d() == 0, "bad initial value")
- setSrc(2)
- assert(d() == 2, "bad updated value")
-end)
-
-test("should handle flags are indirectly updated during checkDirty", function()
- local a, setA = signal(false)
- local b = computed(function()
- return a()
- end)
- local c = computed(function()
- b()
- return 0
- end)
- local d = computed(function()
- c()
- return b()
- end)
-
- assert(d() == false, "bad initial value")
- setA(true)
- assert(d() == true, "bad updated value")
-end)
-
-test("should not update if the signal value is reverted", function()
- local times = 0
-
- local src, setSrc = signal(0)
- local c1 = computed(function()
- times += 1
- return src()
- end)
- c1()
- assert(times == 1, "computed did not run")
- setSrc(1)
- setSrc(0)
- c1()
- assert(times == 1, "computed ran when it should not")
-end)
-
-test("should receive previous value", function()
- local calls = {}
- local src, setSrc = signal(0)
- local c1 = computed(function(current): number
- local incoming = src()
- return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
- end)
-
- effect(function()
- table.insert(calls, c1())
- end)
-
- setSrc(2)
- setSrc(3)
- assert(c1() == 2, "should skip odd value")
-
- setSrc(4)
- setSrc(5)
- assert(c1() == 4, "should skip odd value")
- assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
-end)
-
-return {}
diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau
deleted file mode 100644
index 4189f87..0000000
--- a/packages/charm/src/__tests__/gc.spec.luau
+++ /dev/null
@@ -1,66 +0,0 @@
-local Charm = require("../")
-local suite = require("../../../../test/suite")
-
-local atom = Charm.atom
-local computed = Charm.computed
-local effect = Charm.effect
-local effectScope = Charm.effectScope
-local test = suite.test
-
---- Creates a weak reference to a value.
-local function weakRef(value: T): { value: T? }
- return setmetatable({ value = value }, { __mode = "v" }) :: any
-end
-
---- Triggers garbage collection by creating a large number of tables.
-local function gc()
- for _ = 1, 2000 do
- local _ = table.create(100, 0)
- end
-end
-
---- In alien-signals, a computed value unlinks from dependencies when its last
---- parent scope is disposed. However, calling a computed value outside of a
---- scope still links it to dependencies, which act as strong references.
---- Computed values called in this way won't GC until unlinked from a scope.
----
---- This function releases a computed value by linking it to a scope and then
---- immediately closing it, causing alien-signals to unlink the computed value
---- from its dependencies and allow it to be garbage collected.
-local function unlink(getter: () -> ())
- effectScope(function()
- getter()
- end)()
-end
-
-test("should release computed cache", function()
- local srcRef = weakRef({})
- local src = atom(srcRef.value)
- local c = computed(function()
- return src()
- end)
-
- c() -- cache src value
- src(nil) -- release value
- c = unlink(c) -- release computed
-
- gc()
- assert(srcRef.value == nil, "should release computed cache")
-end)
-
-test("should not release effect for atom", function()
- local effectCalls = 0
- local src = atom(0)
-
- effect(function()
- effectCalls += 1
- src()
- end)
- assert(effectCalls == 1, "should call effect once")
-
- gc()
- src(src() + 1)
- assert(effectCalls == 2, "should call effect again")
-end)
-
-return {}
diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau
deleted file mode 100644
index b786135..0000000
--- a/packages/charm/src/__tests__/peek.spec.luau
+++ /dev/null
@@ -1,147 +0,0 @@
-local Charm = require("../")
-local suite = require("../../../../test/suite")
-
-local signal = Charm.signal
-local computed = Charm.computed
-local effect = Charm.effect
-local peek = Charm.peek
-local test = suite.test
-
-test("should not run detached", function()
- local a, setA = signal(1)
- local spyCalls = 0
-
- effect(function()
- a()
- peek(function()
- effect(function()
- return function()
- spyCalls += 1
- end
- end)
- end)
- end)
-
- assert(spyCalls == 0, "should not be called yet")
- setA(2)
- assert(spyCalls == 1, "should be called once")
- setA(3)
- assert(spyCalls == 2, "should be called twice")
-end)
-
-test("should block tracking inside effects", function()
- local a, setA = signal(1)
- local b, setB = signal(2)
- local spyCalls = 0
-
- effect(function()
- peek(function()
- spyCalls += 1
- return a() + b()
- end)
- end)
- assert(spyCalls == 1, "should be called once")
-
- setA(10)
- setB(20)
- assert(spyCalls == 1, "should be called once")
-end)
-
-test("should block tracking even when run inside effect run inside peek", function()
- local s, setS = signal(1)
- local spyCalls = 0
-
- peek(function()
- effect(function()
- peek(function()
- spyCalls += 1
- return s()
- end)
- end)
- end)
- assert(spyCalls == 1, "should be called once")
-
- setS(2)
- assert(spyCalls == 1, "should be called once")
-end)
-
-test("should not cause signal assignments to throw", function()
- local a, setA = signal(1)
- local aChangedTime, setAChangedTime = signal(0)
-
- local dispose = effect(function()
- a()
- peek(function()
- setAChangedTime(aChangedTime() + 1)
- end)
- end)
-
- setA(2)
- assert(aChangedTime() == 2, "aChangedTime should be 2")
- setA(3)
- assert(aChangedTime() == 3, "aChangedTime should be 3")
-
- dispose()
-end)
-
-test("should block tracking inside computed signals", function()
- local a, setA = signal(1)
- local b, setB = signal(2)
- local spyCalls = 0
- local c = computed(function()
- return peek(function()
- spyCalls += 1
- return a() + b()
- end)
- end)
-
- assert(spyCalls == 0, "should not be called yet")
- assert(c() == 3, "c should be 3")
- setA(10)
- c()
- setB(20)
- c()
- assert(spyCalls == 1, "should be called once")
- assert(c() == 3, "c should be 3")
-end)
-
-test("should track outside peek", function()
- local a, setA = signal(1)
- local b, setB = signal(2)
- local c, setC = signal(3)
- local spyCalls = 0
-
- effect(function()
- a()
- peek(c)
- b()
- spyCalls += 1
- end)
-
- assert(spyCalls == 1, "should be called once")
- setA(10)
- assert(spyCalls == 2, "should be called twice")
- setB(20)
- assert(spyCalls == 3, "should be called three times")
- setC(30)
- assert(spyCalls == 3, "should not run on c change")
-end)
-
-test("should stack peek", function()
- local a, setA = signal(1)
- local spyCalls = 0
-
- effect(function()
- peek(function()
- peek(a)
- return a()
- end)
- spyCalls += 1
- end)
-
- assert(spyCalls == 1, "should be called once")
- setA(2)
- assert(spyCalls == 1, "should not be called twice")
-end)
-
-return {}
diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau
deleted file mode 100644
index b95c070..0000000
--- a/packages/charm/src/__tests__/spec.luau
+++ /dev/null
@@ -1,43 +0,0 @@
-local suite = require("../../../../test/suite")
-
-suite.describe("computed", function()
- require("./computed.spec")
-end)
-suite.describe("effect", function()
- require("./effect.spec")
-end)
-suite.describe("effectScope", function()
- require("./effectScope.spec")
-end)
-suite.describe("gc", function()
- require("./gc.spec")
-end)
-suite.describe("listen", function()
- require("./listen.spec")
-end)
-suite.describe("mapped", function()
- require("./mapped.spec")
-end)
-suite.describe("observe", function()
- require("./observe.spec")
-end)
-suite.describe("peek", function()
- require("./peek.spec")
-end)
-suite.describe("signal", function()
- require("./signal.spec")
-end)
-suite.describe("subscribe", function()
- require("./subscribe.spec")
-end)
-suite.describe("topology", function()
- require("./topology.spec")
-end)
-suite.describe("trigger", function()
- require("./trigger.spec")
-end)
-suite.describe("untracked", function()
- require("./untracked.spec")
-end)
-
-return {}
diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts
index cc27c44..e4689e5 100644
--- a/packages/charm/src/index.d.ts
+++ b/packages/charm/src/index.d.ts
@@ -1,15 +1,15 @@
type Key = string | number | symbol;
+
type Cleanup = () => void;
export enum ReactiveFlags {
- None = 0b0000000,
- Mutable = 0b0000001,
- Watching = 0b0000010,
- RecursedCheck = 0b0000100,
- Recursed = 0b0001000,
- Dirty = 0b0010000,
- Pending = 0b0100000,
- Peeking = 0b1000000,
+ None = 0b000000,
+ Mutable = 0b000001,
+ Watching = 0b000010,
+ RecursedCheck = 0b000100,
+ Recursed = 0b001000,
+ Dirty = 0b010000,
+ Pending = 0b100000,
}
export interface ReactiveNode {
@@ -31,11 +31,15 @@ export interface Link {
}
export interface Atom {
- (newValue: T | ((currentValue: T) => T)): T;
+ (update: Update): T;
(): T;
}
-export type Setter = (newValue: T | ((currentValue: T) => T)) => T;
+export type Getter = () => T;
+
+export type Setter = (update: Update) => T;
+
+export type Update = ((currentValue: T) => T) | T;
export type Equals = (current: T, incoming: T) => boolean;
@@ -46,12 +50,12 @@ export const flags: {
/**
* Enforces synchronous, non-yielding behavior in signals and effects.
* Also enables state validation in Charm Sync to catch sync errors early.
- * Enabled if the optimization level is below 2, which is true in studio.
+ * Enabled by default in Roblox Studio.
*/
strict: boolean;
/**
* Enforces immutability of tables by deep-freezing table values. Enabled
- * if the optimization level is below 2, which is true in studio.
+ * by default in Roblox Studio.
*/
frozen: boolean;
/**
@@ -71,13 +75,8 @@ export const flags: {
* @returns A tuple containing the getter and setter functions for the signal.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
*/
-export function signal(
- initialValue: T,
- equals?: Equals,
-): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>;
-export function signal(): LuaTuple<
- [getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined]
->;
+export function signal(initialValue: T, equals?: Equals): LuaTuple<[getter: Getter, setter: Setter]>;
+export function signal(): LuaTuple<[getter: Getter, setter: Setter]>;
/**
* Creates a reactive atom that acts as both a signal getter and setter.
@@ -99,7 +98,7 @@ export function atom(): Atom;
* @param getter A function that produces the next value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
*/
-export function computed(getter: (previousValue?: T) => T): () => T;
+export function computed(getter: (previousValue?: T) => T): Getter;
/**
* Creates an effect that runs a callback in response to signal state changes.
@@ -150,10 +149,12 @@ export function trigger(fn: () => void): void;
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
/**
- * Runs the function without subscribing to signal updates or capturing inner
- * effects in the parent effect or scope.
+ * Runs the function without subscribing to signal updates and prevents the
+ * current effect or scope from tracking inner effects created within the
+ * function.
*
- * For an alternative that still cleans up inner effects, use `peek` instead.
+ * To avoid tracking signals while still allowing inner effects to be tracked
+ * by parent effects/scopes, use `effectScope()` instead.
*
* @param fn A function that may read signals or create effects.
* @param args Arguments to pass to the function.
@@ -162,19 +163,6 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
*/
export function untracked(fn: (...args: Args) => Result, ...args: Args): Result;
-/**
- * Runs the function without subscribing to signal updates, but still captures
- * inner effects so that they dispose with the parent effect.
- *
- * To avoid capturing inner effects, use `untracked` instead.
- *
- * @param fn A function that may read signals.
- * @param args Arguments to pass to the function.
- * @returns The return value of the function.
- * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
- */
-export function peek(fn: (...args: Args) => Result, ...args: Args): Result;
-
/**
* Combines multiple updates into a single "commit" that runs effects and
* computed signals after the provided function finishes running.
@@ -191,54 +179,51 @@ export function batch(fn: (...args: Args) => Result,
* when the value returned by the getter changes. The callback receives the new
* value and the previous value as arguments.
*
- * Effects created within the callback are cleaned up when the listener runs
- * or the listener is disposed.
- *
* @param getter A function that returns the value to watch.
- * @param fn The function to run when the value changes.
+ * @param callback The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/
-export function listen(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup;
+export function listen(getter: Getter, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
/**
* Creates an effect that only runs the callback when the value returned by the
* getter changes. The callback receives the new value and the previous value
* as arguments.
*
- * Effects created within the callback are cleaned up when the listener runs
- * or the listener is disposed.
- *
* @param getter A function that returns the value to watch.
- * @param fn The function to run when the value changes.
+ * @param callback The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
*/
-export function subscribe(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup;
+export function subscribe(getter: Getter, callback: (value: T, previousValue: T) => void): Cleanup;
/**
* Creates a new read-only signal that is computed by mapping over the entries
- * of an existing atom. If the key is omitted from the mapper's return value,
- * the original key is preserved.
+ * of the source table. If the key is omitted from the transform function's
+ * return value, the original key is preserved.
*
- * @param getter Returns the table to map over.
- * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
- * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
+ * This function is optimized to minimize unnecessary updates by only calling
+ * the transform function for entries that have changed since the last run.
+ *
+ * @param getter A signal or function that returns a table
+ * @param transform Function that maps each entry from the source to a new entry in the mapped table
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
*/
export function mapped(
- getter: () => Map | ReadonlyMap,
- mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
-): () => ReadonlyMap;
+ getter: Getter