diff --git a/.eslintrc b/.eslintrc
deleted file mode 100644
index 8f628fe..0000000
--- a/.eslintrc
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "jsx": true,
- "useJSXTextNode": true,
- "ecmaVersion": 2018,
- "sourceType": "module",
- "project": "./tsconfig.json"
- },
- "plugins": [
- "@typescript-eslint",
- "roblox-ts",
- "prettier"
- ],
- "extends": [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:roblox-ts/recommended",
- "plugin:prettier/recommended"
- ],
- "rules": {
- "prettier/prettier": "warn",
- "@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/no-unsafe-declaration-merging": "off"
- }
-}
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 58d6f77..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,2 +0,0 @@
-* text=auto eol=lf
-*.luau linguist-language=Lua
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8561d3..912f2ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,36 +1,32 @@
name: CI
-on:
- push:
- branches:
- - main
-
- pull_request:
- branches:
- - main
+on: [push, pull_request]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3
with:
- version: 8.15.4
+ submodules: true
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: true
+ - uses: CompeyDev/setup-rokit@v0.1.2
- - name: Install Aftman
- uses: ok-nick/setup-aftman@v0.4.2
-
- - name: Install dependencies
- run: |
- pnpm install
- pnpm wally
+ - name: Setup Lute
+ run: lute setup --with-luaurc
- name: Check code quality
- run: pnpm lint
+ run: lute run check
- - name: Run ESLint
- run: pnpm eslint
+ - name: Run ESLint and Prettier
+ run: |
+ pnpm eslint packages
+ pnpm prettier --check packages
- - name: Build model
- run: rojo build -o charm.rbxm
+ - name: Run tests
+ run: lute run test
+
+ - name: Build models
+ run: lute run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d02e659..3bd512d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,17 +9,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - uses: CompeyDev/setup-rokit@v0.1.2
- - name: Install Aftman
- uses: ok-nick/setup-aftman@v0.4.2
+ - name: Setup Lute
+ run: lute setup --with-luaurc
- - name: Install dependencies
- run: wally install
+ - name: Build models
+ run: lute run build
- - name: Build model
- run: rojo build -o charm.rbxm
-
- - uses: AButler/upload-release-assets@v3.0
+ - name: Upload release assets
+ uses: AButler/upload-release-assets@v3.0
with:
- files: "charm.rbxm"
+ files: "build/*"
repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index f57862f..d217e96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,13 @@
-/node_modules
-/Packages
-/DevPackages
+/.vscode
+/.pesde
+/build
+/packages/**/wally.lock
+/packages/**/LICENSE*
+/packages/**/README.md
/sourcemap.json
+node_modules
*.tsbuildinfo
+*.rbxm
*.tgz
.DS_Store
+roblox.d.luau
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..409278b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "tests/test"]
+ path = tests/test
+ url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
diff --git a/.luaurc b/.luaurc
index e2b625c..c48b0f2 100644
--- a/.luaurc
+++ b/.luaurc
@@ -1,3 +1,7 @@
{
- "languageMode": "strict"
+ "languageMode": "strict",
+ "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/.prettierrc b/.prettierrc
index 701a273..537ac33 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,6 +1,6 @@
{
- "printWidth": 120,
- "tabWidth": 2,
- "trailingComma": "all",
- "useTabs": true
+ "printWidth": 120,
+ "tabWidth": 4,
+ "trailingComma": "all",
+ "useTabs": true
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index e6aa02a..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "luau-lsp.sourcemap.rojoProjectFile": "dev.project.json",
- "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"],
- "luau-lsp.completion.imports.requireStyle": "alwaysRelative"
-}
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 d92304d..07d9641 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-
+
Charm
- Atomic state management for Roblox.
+ Fine-grained reactivity for Roblox
npm package →
@@ -14,27 +14,139 @@

[](https://www.npmjs.com/package/@rbxts/charm)
-[](LICENSE.md)
+[](LICENSE)
-**Charm** is an atomic state management library inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Designed to be a more composable alternative to Reflex, Charm aims to address common criticisms of Rodux-like state containers to better address certain use cases.
+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.
-## 🍀 Features
+**Build game state from simple building blocks:**
-- ⚛️ **Manage state with _atoms_.** Decompose state into small, distinct containers called _atoms_, as opposed to combining them into a single store.
+- 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
-- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state.
+**Want to learn more about reactivity?**
-- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness.
+- 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://github.com/roblox/signals
-- 🦄 **Like magic.** Selector functions can be subscribed to as-is — their memoization and dependencies are resolved for you.
+[Migrating from an older version of Charm?](#migration)
----
+
+Table of Contents
-## 📦 Setup
+- [Installation](#installation)
+- [Reference](#reference)
+ - [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
+ - [`computed(getter)`](#computedgetter)
+ - [`effect(callback)`](#effectcallback)
+ - [Nested effects](#nested-effects)
+ - [`effectScope(callback)`](#effectscopecallback)
+ - [`listen(getter, callback)`](#listengetter-callback)
+ - [Getter functions](#getter-functions)
+ - [`observe(getter, callback)`](#observegetter-callback)
+ - [`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)
+ - [`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)
-Install Charm for roblox-ts using your package manager of choice.
+
+
+## At a Glance
+
+```luau
+local getTodos, setTodos = signal({} :: { string })
+local getQuery, setQuery = signal("")
+
+observe(getTodos, function(todo: string, index: number)
+ local instance = Instance.new("TextLabel")
+
+ local getText = computed(function()
+ return getTodos()[index] or ""
+ end)
+
+ effect(function()
+ instance.Text = getText()
+ instance.Visible = string.match(getText(), getQuery()) ~= nil
+ end)
+
+ instance.LayoutOrder = index
+ instance.Size = UDim2.new(1, 0, 0, 40)
+ instance.Parent = screenGui
+
+ return function()
+ instance:Destroy()
+ end
+end)
+
+setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
+setQuery("Buy")
+```
+
+
+Explain code
+
+```luau
+-- 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 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)
+
+ -- Update instance properties when the text or query updates
+ effect(function()
+ instance.Text = getText()
+ instance.Visible = string.match(getText(), getQuery()) ~= nil
+ end)
+
+ instance.LayoutOrder = index
+ instance.Size = UDim2.new(1, 0, 0, 40)
+ instance.Parent = screenGui
+
+ -- Destroy the instance when this item is removed
+ return function()
+ instance:Destroy()
+ end
+end)
+
+-- Add items to the todo list
+setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
+-- Filter for items containing "Buy"
+setQuery("Buy")
+```
+
+
+
+## Installation
```sh
npm install @rbxts/charm
@@ -42,470 +154,966 @@ yarn add @rbxts/charm
pnpm add @rbxts/charm
```
-Alternatively, add `littensy/charm` to your `wally.toml` file.
-
```toml
+# wally.toml
[dependencies]
Charm = "littensy/charm@VERSION"
```
---
-## 🐛 Debugging
+## Reference
-Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` at the entry point of your project.
+### `signal(initialValue, equals?)`
-Enabling `__DEV__` adds a few helpful features:
+Signals are the core of reactivity in Charm. The `signal` function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value.
-- Better error handling for molecules, listeners, and batched functions:
+```luau
+local getCounter, setCounter = signal(0)
- - Errors provide the function's name and line number.
- - Yielding in these functions will throw an error.
-
-- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client.
-
-Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead.
-
----
-
-## 📚 Reference
-
-### `atom(state, options?)`
-
-Call `atom` to create a state container with the value `state`.
-
-```ts
-const nameAtom = atom("John");
-const todosAtom = atom([]);
+setCounter(1)
+setCounter(function(count)
+ return count + 1
+end)
+print(getCounter()) -- 2
```
-#### Parameters
+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.
-- `state`: The value to assign to the atom initially.
+You can also pass a custom equality function to only update the signal if the equality function returns `false`:
-- **optional** `options`: An object that configures the behavior of this atom.
+```luau
+local getMax, setMax = signal(0, function(current, incoming)
+ return incoming <= current
+end)
- - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`===`) is used.
-
-#### Returns
-
-The `atom` constructor returns an atom function with two possible operations:
-
-1. **Read the state.** Call the atom without arguments to get the current state.
-2. **Set the state.** Pass a new value or an updater function to change the state.
-
-```ts
-function newTodo() {
- nameAtom("Jane");
- nameAtom(); // "Jane"
- todosAtom((todos) => [...todos, "New todo"]);
-}
+setMax(1) -- 1
+setMax(2) -- 2
+setMax(-1) -- 2
```
----
-
-### `subscribe(atom, listener)`
-
-Call `subscribe` to listen for changes to an atom. Changes to the atom will immediately notify all subscribers, passing the new state and the previous state as arguments.
-
-```ts
-const nameAtom = atom("John");
-
-subscribe(nameAtom, (name, prevName) => {
- print(name);
-});
-```
-
-You may also pass a _molecule_, or a function that derives a value from one or more atoms. The molecule will be memoized and only re-run when its dependencies change.
-
-```ts
-const getUppercase = () => nameAtom().upper();
-
-subscribe(getUppercase, (name) => {
- print(name);
-});
-```
-
-#### Parameters
-
-- `atom`: An atom or molecule that you want to subscribe to. This can be an atom, or a function that reads from one or more atoms.
-
-- `listener`: A function that will be called whenever the atom changes. The listener will receive the new state and the previous state as arguments.
-
-#### Returns
-
-`subscribe` returns a cleanup function.
+> [!NOTE]
+> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter.
---
### `effect(callback)`
-Call `effect` to declare a side effect that runs when any atom that it depends on changes. The effect will run immediately and whenever its dependencies change.
+Effects are fundamental to reactivity, allowing you to react to signal updates. The `effect` function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute.
-```ts
-const nameAtom = atom("John");
+```luau
+local getCounter, setCounter = signal(0)
-effect(() => {
- print(nameAtom());
- return () => {
- print("Changing name!");
- };
-});
+effect(function()
+ print(`Count is {getCounter()}`)
+end) -- Count is 0
+
+setCounter(1) -- Count is 1
```
-#### Parameters
+You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed:
-- `callback`: The function that runs your effect. The function is called once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run.
+```luau
+local getCounter, setCounter = signal(0)
-#### Returns
+local dispose = effect(function()
+ local count = getCounter()
+ return function()
+ print(`Cleanup {count}`)
+ end
+end)
-`effect` returns a cleanup function.
+setCounter(1) -- Cleanup 0
+dispose() -- Cleanup 1
+```
+
+### 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 getPrintCount, setPrintCount = signal(true)
+local getCount, setCount = signal(1)
+
+effect(function()
+ if getPrintCount() then
+ -- This inner effect is created when getPrintCount() is true
+ effect(function()
+ print(`Count is {getCount()}`)
+ end)
+ end
+end) -- Count is 1
+
+setCount(2) -- Count is 2
+
+-- This re-runs the outer effect and cleans up old inner effects
+setPrintCount(false)
+
+setCount(3) -- No output
+```
+
+> [!NOTE]
+> To run code that is "detached" from the parent effect or scope, use `untracked()` or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the `flags.trackInnerEffects` flag to assist with debugging.
---
-### `computed(molecule, options?)`
+### `computed(getter)`
-Call `computed` when you want to derive an expensive value from one or more atoms. The derived value will be memoized and only re-run when its dependencies change.
+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.
-```ts
-const todosAtom = atom([]);
-const mapToUppercase = computed(() => {
- return todosAtom().map((todo) => todo.upper());
-});
+```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"
```
-`computed` is useful when you have multiple subscribers that depend on the same derived value. By memoizing the value, you can avoid re-calculating it for each subscriber.
+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:
-#### Parameters
+```luau
+local getCounter, setCounter = signal(10)
+local getMax = computed(function(prevMax)
+ return math.max(getCounter(), prevMax or 0)
+end)
-- `molecule`: A function that reads from one or more atoms and returns a derived value.
-
-- **optional** [`options`](#parameters): An object that configures the behavior of this atom.
-
-#### Returns
-
-`computed` returns a read-only atom.
+print(getMax()) -- 10
+setCounter(5)
+print(getMax()) -- 10
+```
---
-### `observe(atom, factory)`
+### `effectScope(callback)`
-Call `observe` to run the factory when a key is added to the atom's state. Your factory can return a cleanup function to run when the key is removed or the observer is disposed.
+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.
-```ts
-const todosAtom = atom<{ [Id in string]?: Todo }>({});
+```luau
+local getCounter, setCounter = signal(0)
-observe(todosAtom, (todo, key) => {
- print(todo);
- return () => {
- print("Removing todo!");
- };
-});
+local dispose = effectScope(function()
+ effect(function()
+ print(`Count 1 is {getCounter()}`)
+ end)
+ effect(function()
+ print(`Count 2 is {getCounter()}`)
+ end)
+end)
+
+setCounter(1) -- Count 1 is 1, Count 2 is 1
+dispose()
+setCounter(2) -- No output; effects got disposed
```
-#### Parameters
-
-- `atom`: An atom or molecule that you want to observe. This can be a primitive atom, or a function that reads from one or more atoms. The atom should return a dictionary or an array of objects.
-
-- `factory`: A function called for each key in the atom's state. The factory will receive the entry as an argument and should return a cleanup function.
-
-#### Returns
-
-`observe` returns a cleanup function.
-
-#### Caveats
-
-- The factory will only run when a key is added or removed, not when the value at that key changes. If your data is not keyed by a unique and stable identifier, consider using `mapped` to transform it into a keyed object before passing it to `observe`.
+Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed.
---
-### `mapped(atom, mapper)`
+### `listen(getter, callback)`
-Call `mapped` to transform the key-value pairs of an atom's state. The mapper function will be called for each key-value pair in the atom's state, and the result will be stored in a new atom.
+The `listen` function creates an effect that only subscribes to the signals accessed by `getter`. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects.
-```ts
-const todosAtom = atom([]);
-const todosById = mapped(todosAtom, (todo, index) => {
- return $tuple(todo, todo.id);
-});
+The callback also receives the previous value, or `nil` when running for the first time.
+
+```luau
+local getCounter, setCounter = signal(0)
+
+listen(getCounter, function(count, prevCount)
+ print(`Count is {count} (was {prevCount})`)
+end) -- Count is 0 (was nil)
+
+setCounter(1) -- Count is 1 (was 0)
```
-#### Parameters
+Note that the listener callback runs in `untracked()`, so nested effects are not cleaned up.
-- `atom`: An atom or molecule that you want to map. This can be a primitive atom, or a function that reads from one or more atoms. The atom should return a dictionary or an array of objects.
+### Getter functions
-- `mapper`: A function called for each key-value pair in the atom's state. The mapper will receive the entry as an argument and should return a new key-value pair. The value you return determines the type of the mapped atom:
+In most Charm APIs, you can also subscribe to getter functions that call one or more signals, and they will automatically be tracked:
- - If you return a tuple, the mapped atom returns a dictionary with the first element as the value and the second element as the key.
+```luau
+local getCounter, setCounter = signal(0)
- - If you return a value without a key, the mapped atom returns an array of the given values in the order they were mapped.
+local function floorCounter()
+ return math.floor(getCounter())
+end
- - If the first element is `undefined`, the entry will be omitted from the mapped atom.
+listen(floorCounter, function(count, prevCount)
+ print(`Floor of count is {count} (was {prevCount})`)
+end) -- Floor of count is 0 (was nil)
-#### Returns
-
-`mapped` returns a read-only atom.
+setCounter(0.5) -- Doesn't print anything, floor is still 0
+setCounter(1) -- Floor of count is 1 (was 0)
+```
---
-### `peek(value)`
+### `observe(getter, callback)`
-Call `peek` to get the current state of an atom without tracking it as a dependency in functions like `effect` and `subscribe`.
+[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.
-```ts
-const nameAtom = atom("John");
-const ageAtom = atom(25);
+```luau
+local getItems, setItems = signal({ a = 0, b = 0 })
-effect(() => {
- const name = nameAtom();
- const age = peek(ageAtom);
-});
+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
```
-#### Parameters
+The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:
-- `value`: Any value. If the value is an atom, `peek` will return the current state of the atom without tracking it. Otherwise, it will return the value as-is.
+```luau
+local getItems, setItems = signal({})
-- **optional** `...args`: Additional arguments to pass to the value if it is a function.
+local dispose = observe(getItems, function(value, key)
+ local getValue = computed(function(prevValue)
+ return getItems()[key] or prevValue
+ end)
-#### Returns
+ effect(function()
+ local value = getValue()
+ print(`Set {key} = {value}`)
+ return function()
+ print(`Cleanup {key} = {value}`)
+ end
+ end)
+end)
-`peek` returns the current state of the atom. If the value is not a function, it will return the value as-is.
+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
+```
+
+---
+
+### `subscribe(getter, callback)`
+
+The `subscribe` function is identical to `listen()`, but the callback does not run initially. The callback only runs when the value returned by the getter function changes.
+
+```luau
+local getCounter, setCounter = signal(0)
+
+-- Does not output anything initially
+subscribe(getCounter, function(count, prevCount)
+ print(`Count is {count} (was {prevCount})`)
+end)
+
+setCounter(1) -- Count is 1 (was 0)
+```
+
+---
+
+### `untracked(callback)`
+
+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 getTracked, setTracked = signal(0)
+local getUntracked, setUntracked = signal(0)
+
+effect(function()
+ print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
+end) -- Tracked: 0, Untracked: 0
+
+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 during the execution of the callback will not be 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
+```
---
### `batch(callback)`
-Call `batch` to group multiple state changes into a single update. The callback will run immediately and listeners will only be notified once all changes have been applied.
+Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
-```ts
-const nameAtom = atom("John");
-const ageAtom = atom(25);
+```luau
+local getName, setName = signal("John")
+local getSurname, setSurname = signal("Doe")
-batch(() => {
- nameAtom("Jane");
- ageAtom(26);
-});
+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)
```
-#### Parameters
-
-- `callback`: A function that makes multiple state changes. The changes will be batched together and listeners will only be notified once all changes have been applied.
-
-#### Returns
-
-`batch` does not return anything.
-
---
-## 📘 React
+### `mapped(getter, transform)`
-### `useAtom(atom, dependencies?)`
+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.
-Call `useAtom` at the top-level of a React component to read from an atom.
+The first value returned by the transform function is used as the new value:
-```tsx
-import { useAtom } from "@rbxts/charm";
-import { todosAtom } from "./todos-atom";
+```luau
+local getList, setList = signal({ "a", "b", "c" })
-function TodosApp() {
- const todos = useAtom(todosAtom);
- // ...
+local getUppercase = mapped(getList, function(value)
+ return string.upper(value)
+end)
+
+print(getUppercase()) -- { "A", "B", "C" }
+```
+
+If the transform function returns two values, the second value is used as the new key:
+
+```luau
+local getList, setList = signal({ "a", "b", "c" })
+
+local getSwapped = mapped(getList, function(value, key)
+ return key, value
+end)
+
+print(getSwapped()) -- { a = 1, b = 2, c = 3 }
+```
+
+---
+
+### `onCleanup(callback, failSilently?)`
+
+The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect.
+
+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()
+ onCleanup(function()
+ print("Cleaned up")
+ end)
+end)
+
+dispose() -- Cleaned up
+```
+
+---
+
+### `atom(initialValue, equals?)`
+
+The `atom` function creates a new reactive signal and returns a single function that acts as both a getter and setter.
+
+If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value.
+
+```luau
+local counter = atom(0)
+
+print(counter()) -- 0
+counter(1)
+counter(function(count)
+ return count + 1
+end)
+```
+
+You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value:
+
+```luau
+local max = atom(0, function(current, incoming)
+ return incoming <= current
+end)
+
+max(1) -- 1
+max(-1) -- 1
+```
+
+---
+
+### `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:
+
+```luau
+local array = signal({} :: { number })
+local length = computed(function()
+ return #array()
+end)
+
+print(length()) -- 0
+
+-- Direct mutation doesn't automatically trigger updates
+table.insert(array(), 1)
+print(length()) -- Still 0
+
+-- Manually trigger updates
+trigger(array)
+print(length()) -- 1
+```
+
+You can also trigger multiple signals at once:
+
+```luau
+local src1 = signal({} :: { number })
+local src2 = signal({} :: { number })
+local total = computed(function()
+ return #src1() + #src2()
+end)
+
+table.insert(src1(), 1)
+table.insert(src2(), 2)
+
+trigger(function()
+ src1()
+ src2()
+end)
+
+print(total()) -- 2
+```
+
+---
+
+### `flags`
+
+Charm exposes the following global flags to customize behavior:
+
+| 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.
+
+---
+
+## Client-Server Sync
+
+### Installation
+
+```sh
+npm install @rbxts/charm-sync
+yarn add @rbxts/charm-sync
+pnpm add @rbxts/charm-sync
+```
+
+```toml
+[dependencies]
+CharmSync = "littensy/charm-sync@VERSION"
+```
+
+### Quick Start
+
+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
+-- store.luau
+
+local getStatus, setStatus = signal("intermission")
+local timerAtom = atom(20)
+local getInventories, setInventories = signal({} :: { [Username]: Inventory })
+
+return {
+ getStatus = getStatus,
+ setStatus = setStatus,
+ timerAtom = timerAtom,
+ getInventories = getInventories,
+ setInventories = setInventories,
}
```
-By default, the atom is subscribed to once when the component initially mounts. Optionally, you may pass an array of dependencies to `useAtom` if your atom should be memoized based on other values.
+#### Server setup
-```tsx
-const todos = useAtom(searchTodos(filter), [filter]);
+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
+-- sync.server.luau
+
+playerReadyEvent.OnServerEvent:Connect(function(player)
+ -- Sync signal getters, computed signals, atoms, or reactive proxies
+ server.addSignalsToClient(player, {
+ 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)
+
+Players.PlayerRemoving:Connect(function(player)
+ server.removeClient(player)
+end)
+
+server.connect(function(player, updates)
+ -- 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)
```
-#### Parameters
+> [!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.
-- `atom`: An atom or molecule that you want to read from. This can be an atom, or a function that reads from one or more atoms.
+#### Client setup
-- **optional** `dependencies`: An array of values that the atom depends on. If the dependencies change, the atom will be re-subscribed to.
+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.
-#### Returns
+After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
-`useAtom` returns the current state of the atom.
+```luau
+-- 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({
+ 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)
+```
---
-## 📗 Charm Sync
+### `config`
-### `sync.client(options)`
+A configuration table that customizes the behavior of Charm Sync on the server.
-Call `sync.client` to create a client sync object. The object will sync the client's copy of the state with the server's state.
-
-```ts
-import { sync } from "@rbxts/charm";
-
-const client = sync.client({ atoms: atomsToSync });
-
-remotes.syncState.connect((payload) => {
- client.sync(payload);
-});
-
-remotes.requestState.fire();
-```
-
-#### Parameters
-
-- `options`: An object to configure the client syncer.
-
- - `atoms`: An object containing the atoms to sync. The keys should match the keys on the server.
-
-#### Returns
-
-`sync.client` returns a client sync object. The sync object has the following methods:
-
-- `client.sync(payload)` applies a state update from the server.
-
-#### Caveats
-
-- The client sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes requesting the initial state, which is implemented via `requestState` in the example above.
+| Option | Default | Description |
+| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 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 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). |
---
-### `sync.server(options)`
+### `server.addSignalsToClient(client, getters)`
-Call `sync.server` to create a server sync object. The object handles sending state updates to clients at a specified interval, and hydrating clients with the initial state.
+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.
-```ts
-import { sync } from "@rbxts/charm";
+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.
-const server = sync.server({ atoms: atomsToSync });
-
-server.connect((player, payload) => {
- remotes.syncState.fire(player, payload);
-});
-
-remotes.requestState.connect((player) => {
- server.hydrate(player);
-});
+```luau
+playerReadyEvent.OnServerEvent:Connect(function(player)
+ server.addSignalsToClient(player, {
+ status = store.getStatus,
+ timer = store.timerAtom,
+ })
+end)
```
-#### Parameters
+You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal:
-- `options`: An object to configure the server syncer.
+```luau
+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),
- - `atoms`: An object containing the atoms to sync. The keys should match the keys on the client.
+ -- 🔴 Bad: Syncing different signals with the same keys does not work
+ playerData = computed(function()
+ return clientStore.getPlayerData(player.UserId)
+ end),
+ })
+end)
+```
- - **optional** `interval`: The interval at which to send state updates to clients. Defaults to `0`, meaning updates are sent on the next frame.
-
-#### Returns
-
-`sync.server` returns a server sync object. The sync object has the following methods:
-
-- `server.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload.
-
-- `server.hydrate(player)` sends the initial state to a player, calling the callback passed to `connect` with a payload containing the initial state.
-
-#### Caveats
-
-- The server sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes sending the initial state, which is implemented via `requestState` in the example above.
+> [!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.
---
-## 🚀 Examples
+### `server.removeSignalsFromClient(client, ...keys)`
-### Counter atom
+The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`.
-```ts
-import { atom, subscribe } from "@rbxts/charm";
+```luau
+server.addSignalsToClient(player, {
+ status = store.getStatus,
+ timer = store.timerAtom,
+})
-const counterAtom = atom(0);
-
-// Create a derived atom that returns double the counter value
-const doubleCounterAtom = () => counterAtom() * 2;
-
-// Runs after counterAtom is updated and prints double the new value
-subscribe(doubleCounterAtom, (value) => {
- print(value);
-});
-
-counterAtom(1); // 2
-counterAtom((count) => count + 1); // 4
+server.removeSignalsFromClient(player, "status")
```
-### Counter component
+---
-```tsx
-import React from "@rbxts/react";
-import { useAtom } from "@rbxts/charm";
-import { counterAtom, incrementCounter } from "./counter-atom";
+### `server.removeClient(client)`
-function Counter() {
- const count = useAtom(counterAtom);
+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.
- return (
- incrementCounter(),
- }}
- />
- );
+```luau
+playerReadyEvent.OnServerEvent:Connect(function(player)
+ server.addSignalsToClient(player, signals)
+end)
+
+Players.PlayerRemoving:Connect(function(player)
+ server.removeClient(player)
+end)
+```
+
+---
+
+### `server.connect(onSync)`
+
+Binds a callback to run when sending state updates a client. Scheduled updates will be sent periodically at the interval specified in [`config.interval`](#config).
+
+When a synced signal updates, the `onSync` function is scheduled to run for each client subscribed to that signal on the next sync event.
+
+```luau
+server.connect(function(player, updates)
+ syncEvent:FireServer(player, updates)
+end)
+```
+
+---
+
+### `server.disconnect()`
+
+Stops syncing state updates to clients at the automatic interval. This doesn't unbind the last callback, so you can still manually trigger sync events after calling this function by calling `server.flush()`.
+
+```luau
+server.connect(function(player, updates)
+ syncEvent:FireServer(player, updates)
+end)
+
+-- Stops calling the function at the automatic interval
+server.disconnect()
+
+-- Flushing still sends pending updates
+server.flush()
+```
+
+---
+
+### `client.addSignals(setters)`
+
+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, atoms, or [reactive objects](#reactiveinitialvalue) to this function:
+
+```luau
+client.addSignals({
+ 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,
+})
+```
+
+---
+
+### `client.removeSignals(...keys)`
+
+Unsubscribes from each signal with the corresponding keys. The signals will retain their current values, but will no longer receive updates from the server.
+
+```luau
+client.addSignals({
+ status = store.setStatus,
+ timer = store.timerAtom,
+})
+
+client.removeSignals("status")
+```
+
+---
+
+### `client.patch(updates)`
+
+The `patch` function patches the client's state with the updates sent from the server. The initial update sent by the server will be the full state, and later updates will only include the values that changed.
+
+You should call `patch` when receiving updates from the server from a remote event:
+
+```luau
+client.addSignals({
+ status = store.setStatus,
+ timer = store.timerAtom,
+ [`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
+})
+
+syncEvent.OnClientEvent:Connect(function(updates)
+ client.patch(updates)
+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)
```
-### Server-client sync
+---
-Charm provides client and server objects for synchronizing state between the server and clients. Start by defining a module (or creating an object) exporting the atoms you want to sync:
+### Sync Caveats
-```ts
-export { counterAtom } from "./counter-atom";
-export { writerAtom } from "./writer-atom";
+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`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`.
+
+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
```
-Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request.
-
-```ts
-import { sync } from "@rbxts/charm";
-import { remotes } from "./remotes";
-import * as atoms from "./atoms";
-
-const server = sync.server({ atoms });
-
-// Broadcast a state update to a specific player
-server.connect((player, payload) => {
- remotes.syncState.fire(player, payload);
-});
-
-// Send initial state to a player upon request
-remotes.requestState.connect((player) => {
- server.hydrate(player);
-});
+```toml
+[dependencies]
+DeepCharm = "littensy/deep-charm@VERSION"
```
-Finally, on the client, create a client sync object and apply incoming state changes.
+---
-```ts
-import { sync } from "@rbxts/charm";
-import { remotes } from "./remotes";
-import * as atoms from "./atoms";
+### `reactive(initialValue)`
-const client = sync.client({ atoms });
+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.
-// Listen for incoming state changes from the server
-remotes.syncState.connect((payload) => {
- client.sync(payload);
-});
+```luau
+local users, updateUsers = reactive({
+ { name = "John", surname = "Doe" },
+})
-// Request initial state from the server
-remotes.requestState.fire();
+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 several breaking changes, so this section should help you migrate from an older version.
+
+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 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:
+ - You can now sync signals per-client, including computed signals. You shouldn't have to modify sync payloads to filter data anymore.
+ - 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. 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 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 temporarily disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`.
+
+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
+
+- https://github.com/littensy/fishing-minigame: Fisch clone using Charm for server and client state
+
+### React Counter
+
+```luau
+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 = ReactCharm.useSignalState(getCounter)
+
+ return React.createElement("TextButton", {
+ [React.Event.Activated] = function()
+ setCounter(count + 1)
+ end,
+ Text = `Count: {count}`,
+ Size = UDim2.fromOffset(100, 50),
+ })
+end
+```
+
+### Vide Counter
+
+```luau
+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 = VideCharm.useSignalState(getCounter)
+
+ return create "TextButton" {
+ Activated = function()
+ setCounter(function(count)
+ return count + 1
+ end)
+ end,
+ Text = function()
+ return `Count: {count()}`
+ end,
+ Size = UDim2.fromOffset(100, 50),
+ }
+end
```
---
-Charm is released under the MIT License.
+Charm is released under the MIT License.
-[](LICENSE.md)
+[](LICENSE)
diff --git a/aftman.toml b/aftman.toml
deleted file mode 100644
index 187b7df..0000000
--- a/aftman.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-# This file lists tools managed by Aftman, a cross-platform toolchain manager.
-# For more information, see https://github.com/LPGhatguy/aftman
-
-# To add a new tool, add an entry to this table.
-[tools]
-wally = "UpliftGames/wally@0.3.2"
-rojo = "rojo-rbx/rojo@7.4.1"
-wally-package-types = "JohnnyMorganz/wally-package-types@1.3.1"
-stylua = "johnnymorganz/stylua@0.20.0"
-selene = "Kampfkarren/selene@0.26.1"
-luau-lsp = "johnnymorganz/luau-lsp@1.27.1"
diff --git a/images/logo.png b/assets/logo.png
similarity index 100%
rename from images/logo.png
rename to assets/logo.png
diff --git a/benches/bench.luau b/benches/bench.luau
new file mode 100644
index 0000000..ffb839d
--- /dev/null
+++ b/benches/bench.luau
@@ -0,0 +1,48 @@
+local profiles: { [string]: number } = {}
+local times: { [string]: number } = {}
+local stack: { string } = {}
+
+local function run(name: string, test: () -> ())
+ local start = os.clock()
+ local iter = 0
+
+ table.clear(profiles)
+ table.clear(times)
+ table.clear(stack)
+
+ while os.clock() - start < 2 do
+ iter += 1
+ test()
+ end
+
+ local elapsed = os.clock() - start
+ local results = {}
+
+ 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, " | "))
+end
+
+local function profilebegin(name: string)
+ profiles[name] = os.clock()
+ table.insert(stack, name)
+end
+
+local function profileend()
+ local name = table.remove(stack)
+
+ if name then
+ times[name] = (times[name] or 0) + os.clock() - profiles[name]
+ profiles[name] = nil
+ end
+end
+
+return {
+ run = run,
+ profilebegin = profilebegin,
+ profileend = profileend,
+}
diff --git a/benches/mapped.luau b/benches/mapped.luau
new file mode 100644
index 0000000..695b7f5
--- /dev/null
+++ b/benches/mapped.luau
@@ -0,0 +1,84 @@
+local Charm = require("../packages/charm/src")
+local bench = require("./bench")
+
+local computed = Charm.computed
+local mapped = Charm.mapped
+local signal = Charm.signal
+
+type Key = number
+type Value = number
+
+local count = 512
+local initialState: { [Key]: Value } = table.create(count, 0)
+
+local function expensiveOperation()
+ for _ = 1, 100 do
+ continue
+ end
+end
+
+local function simpleMapped(
+ source: () -> { [Key]: Value },
+ mapper: (Value, Key) -> (Value, Key?)
+): () -> { [Key]: Value }
+ return computed(function()
+ local state = source()
+ local nextState = {}
+
+ for keyIn, valueIn in state do
+ local valueOut, keyOut = mapper(valueIn, keyIn)
+ nextState[if keyOut == nil then keyIn else keyOut] = valueOut
+ end
+
+ return nextState
+ end)
+end
+
+local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
+ return function(state)
+ local nextState = table.clone(state)
+ for key in state do
+ if math.random() < updateChance then
+ nextState[key] += 1
+ end
+ end
+ return nextState
+ end
+end
+
+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(updateRandomly(updateChance))
+
+ bench.profilebegin("update")
+ out()
+ bench.profileend()
+ end)
+
+ bench.run(`control {updateChance}`, function()
+ local src, setSrc = signal(initialState)
+ local out = simpleMapped(src, mapper)
+
+ bench.profilebegin("init")
+ out()
+ bench.profileend()
+
+ setSrc(updateRandomly(updateChance))
+
+ bench.profilebegin("update")
+ out()
+ bench.profileend()
+ end)
+end
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/propagate.luau b/benches/propagate.luau
new file mode 100644
index 0000000..463ee2e
--- /dev/null
+++ b/benches/propagate.luau
@@ -0,0 +1,47 @@
+local Charm = require("../packages/charm/src")
+local bench = require("./bench")
+
+local computed = Charm.computed
+local effect = Charm.effect
+local listen = Charm.listen
+local signal = Charm.signal
+
+for _, w in { 10, 100 } do
+ for _, h in { 10, 100 } do
+ bench.run(`propagate {w}*{h}`, function()
+ local src, setSrc = signal(1)
+ for _ = 1, w do
+ local last = src
+ for _ = 1, h do
+ local prev = last
+ last = computed(function()
+ return prev() + 1
+ end)
+ end
+ effect(function()
+ last()
+ end)
+ end
+ setSrc(src() + 1)
+ end)
+ end
+end
+
+for _, w in { 10, 100 } do
+ for _, h in { 10, 100 } do
+ bench.run(`propagate (listen) {w}*{h}`, function()
+ local src, setSrc = signal(1)
+ for _ = 1, w do
+ local last = src
+ for _ = 1, h do
+ local prev = last
+ last = computed(function()
+ return prev() + 1
+ end)
+ end
+ listen(last, function() end)
+ end
+ setSrc(src() + 1)
+ end)
+ end
+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/check.sh b/bin/check.sh
deleted file mode 100644
index e388fe8..0000000
--- a/bin/check.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
-
-rojo sourcemap dev.project.json -o sourcemap.json
-
-luau-lsp analyze \
- --defs=bin/roblox.d.luau \
- --defs=bin/testez.d.luau \
- --flag:LuauFixIndexerSubtypingOrdering=true \
- --flag:LuauInstantiateInSubtyping=true \
- --flag:LuauTinyControlFlowAnalysis=true \
- --sourcemap=sourcemap.json \
- --ignore="**/_Index/**" src
-
-selene src
-stylua --check src
-eslint src
-
-rm bin/roblox.d.luau
diff --git a/bin/install.sh b/bin/install.sh
deleted file mode 100644
index 4091d75..0000000
--- a/bin/install.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-wally install
-rojo sourcemap dev.project.json --output sourcemap.json
-wally-package-types --sourcemap sourcemap.json Packages
-wally-package-types --sourcemap sourcemap.json DevPackages
diff --git a/bin/spec.server.luau b/bin/spec.server.luau
deleted file mode 100644
index 81d6907..0000000
--- a/bin/spec.server.luau
+++ /dev/null
@@ -1,10 +0,0 @@
---!nocheck
-
-local ReplicatedStorage = game:GetService("ReplicatedStorage")
-
-local Packages = ReplicatedStorage.Packages
-local DevPackages = ReplicatedStorage.DevPackages
-
-local TestEZ = require(DevPackages.TestEZ)
-
-TestEZ.TestBootstrap:run({ Packages.charm.__tests__ }, TestEZ.Reporters.TextReporter)
diff --git a/bin/testez.d.luau b/bin/testez.d.luau
deleted file mode 100644
index 28bef92..0000000
--- a/bin/testez.d.luau
+++ /dev/null
@@ -1,24 +0,0 @@
-declare function afterAll(callback: () -> ()): ()
-declare function afterEach(callback: () -> ()): ()
-
-declare function beforeAll(callback: () -> ()): ()
-declare function beforeEach(callback: () -> ()): ()
-
-declare function describe(phrase: string, callback: () -> ()): ()
-declare function describeFOCUS(phrase: string, callback: () -> ()): ()
-declare function fdescribe(phrase: string, callback: () -> ()): ()
-declare function describeSKIP(phrase: string, callback: () -> ()): ()
-declare function xdescribe(phrase: string, callback: () -> ()): ()
-
-declare function expect(value: any): any
-
-declare function FIXME(optionalMessage: string?): ()
-declare function FOCUS(): ()
-declare function SKIP(): ()
-
-declare function it(phrase: string, callback: () -> ()): ()
-declare function itFOCUS(phrase: string, callback: () -> ()): ()
-declare function fit(phrase: string, callback: () -> ()): ()
-declare function itSKIP(phrase: string, callback: () -> ()): ()
-declare function xit(phrase: string, callback: () -> ()): ()
-declare function itFIXME(phrase: string, callback: () -> ()): ()
diff --git a/build.project.json b/build.project.json
new file mode 100644
index 0000000..53d32b4
--- /dev/null
+++ b/build.project.json
@@ -0,0 +1,11 @@
+{
+ "name": "Packages",
+ "tree": {
+ "$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 a485e4d..85d8f55 100644
--- a/default.project.json
+++ b/default.project.json
@@ -1,6 +1,61 @@
{
- "name": "charm",
+ "name": "charm-test",
+ "globIgnorePaths": [
+ "**/default.project.json",
+ "node_modules/charm",
+ "node_modules/charm-sync",
+ "node_modules/react-charm",
+ "node_modules/vide-charm"
+ ],
+ "emitLegacyScripts": false,
"tree": {
- "$path": "src"
+ "$className": "DataModel",
+ "ReplicatedStorage": {
+ "$className": "ReplicatedStorage",
+ "packages": {
+ "$className": "Folder",
+ "vide": {
+ "$path": "node_modules/@rbxts/vide"
+ },
+ "charm": {
+ "$className": "Folder",
+ "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" }
+ },
+ "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" }
+ }
+ },
+ "@rbxts-js": {
+ "$path": "node_modules/@rbxts-js"
+ },
+ "tests": {
+ "$path": "tests"
+ }
+ }
}
}
diff --git a/dev.project.json b/dev.project.json
deleted file mode 100644
index ed0d1b9..0000000
--- a/dev.project.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "charm-dev",
- "tree": {
- "$className": "DataModel",
- "ReplicatedStorage": {
- "$className": "ReplicatedStorage",
- "Packages": {
- "$path": "Packages",
- "ReactLua": { "$path": "Packages" },
- "charm": { "$path": "src" }
- },
- "DevPackages": { "$path": "DevPackages" }
- },
- "TestService": {
- "$className": "TestService",
- "$properties": { "ExecuteWithStudioRun": true },
- "spec": { "$path": "bin/spec.server.luau" }
- }
- }
-}
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/package.json b/package.json
index 9f869ea..d0bf03d 100644
--- a/package.json
+++ b/package.json
@@ -1,57 +1,29 @@
{
- "name": "@rbxts/charm",
- "version": "0.3.1",
- "description": "Atomic state management for Roblox",
+ "private": true,
"scripts": {
- "lint": "sh bin/check.sh",
- "wally": "sh bin/install.sh"
- },
- "main": "src/init.lua",
- "types": "src/index.d.ts",
- "keywords": [
- "roblox",
- "typescript",
- "roblox-ts",
- "state"
- ],
- "author": "littensy",
- "license": "MIT",
- "repository": {
- "type": "git",
- "url": "https://github.com/littensy/charm"
- },
- "bugs": {
- "url": "https://github.com/littensy/charm/issues"
- },
- "homepage": "https://github.com/littensy/charm",
- "files": [
- "src",
- "default.project.json",
- "!src/__tests__"
- ],
- "publishConfig": {
- "access": "public"
+ "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@12.8.1",
"devDependencies": {
- "@rbxts/compiler-types": "^2.3.0-types.0",
- "@rbxts/react": "^0.3.1",
- "@rbxts/types": "^1.0.754",
- "@typescript-eslint/eslint-plugin": "^7.0.2",
- "@typescript-eslint/parser": "^7.0.2",
- "eslint": "^8.56.0",
- "eslint-config-prettier": "^9.1.0",
- "eslint-plugin-prettier": "^5.1.3",
- "eslint-plugin-roblox-ts": "^0.0.36",
- "prettier": "^3.2.5",
- "roblox-ts": "2.3.0-dev-ac7800f",
- "typescript": "^5.3.3"
- },
- "peerDependencies": {
- "@rbxts/react": "*"
- },
- "peerDependenciesMeta": {
- "@rbxts/react": {
- "optional": true
- }
+ "@eslint/js": "^9.39.4",
+ "@rbxts/compiler-types": "3.0.0-types.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.5",
+ "eslint-plugin-roblox-ts": "^1.4.0",
+ "prettier": "^3.8.1",
+ "roblox-ts": "^3.0.0",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.58.0"
}
}
diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau
new file mode 100644
index 0000000..480f2d5
--- /dev/null
+++ b/packages/charm-sync/Charm.luau
@@ -0,0 +1,9 @@
+if not script then
+ return require("../charm/src")
+end
+
+local Charm = require(script.Parent.Parent.charm.src)
+
+export type Atom = Charm.Atom
+
+return Charm
diff --git a/packages/charm-sync/default.project.json b/packages/charm-sync/default.project.json
new file mode 100644
index 0000000..9c44c7c
--- /dev/null
+++ b/packages/charm-sync/default.project.json
@@ -0,0 +1,6 @@
+{
+ "name": "charm-sync",
+ "tree": {
+ "$path": "src"
+ }
+}
diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json
new file mode 100644
index 0000000..f2c84a3
--- /dev/null
+++ b/packages/charm-sync/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@rbxts/charm-sync",
+ "version": "0.4.1",
+ "description": "Simple server-to-client sync for Charm",
+ "license": "MIT",
+ "main": "src/init.luau",
+ "types": "src/index.d.ts",
+ "files": [
+ "src",
+ "*.luau",
+ "!wally.toml",
+ "!wally.lock",
+ "!default.project.json",
+ "!test"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/littensy/charm.git"
+ },
+ "scripts": {
+ "prepare": "cp ../../LICENSE . && cp ../../README.md .",
+ "publish:wally": "wally publish"
+ },
+ "dependencies": {
+ "@rbxts/charm": "workspace:^"
+ }
+}
diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau
new file mode 100644
index 0000000..973a983
--- /dev/null
+++ b/packages/charm-sync/src/client.luau
@@ -0,0 +1,112 @@
+local Charm = require("../Charm")
+local patch = require("./patch")
+local types = require("./types")
+
+type SyncPayload = types.SyncPayload
+type ReactiveProxy = types.ReactiveProxy
+
+local trackedSignals: { [string]: (any) -> any } = {}
+local trackedReactives: { [string]: ReactiveProxy } = {}
+
+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 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.
+]]
+local function removeSignals(...: string)
+ for index = 1, select("#", ...) do
+ local key = select(index, ...)
+ trackedSignals[key] = nil
+ trackedReactives[key] = nil
+ end
+end
+
+--[[
+ Unsubscribes from all server state updates.
+]]
+local function removeAllSignals()
+ table.clear(trackedSignals)
+ table.clear(trackedReactives)
+end
+
+local function applyInitPayload(data: { [string]: any })
+ for key, value in data do
+ 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, 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 applyPatches(payloads: { SyncPayload })
+ for _, payload in payloads do
+ if payload.type == "init" then
+ Charm.batch(applyInitPayload, payload.data)
+ elseif payload.type == "patch" then
+ Charm.batch(applyPatchPayload, payload.data)
+ end
+ end
+end
+
+return {
+ addSignals = addSignals,
+ removeSignals = removeSignals,
+ removeAllSignals = removeAllSignals,
+ patch = applyPatches,
+}
diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau
new file mode 100644
index 0000000..c3ce27c
--- /dev/null
+++ b/packages/charm-sync/src/config.luau
@@ -0,0 +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.
+ ]]
+ 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` for performance reasons.
+ ]]
+ preserveHistory = false,
+ --[[
+ 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 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
new file mode 100644
index 0000000..bb40eba
--- /dev/null
+++ b/packages/charm-sync/src/index.d.ts
@@ -0,0 +1,292 @@
+import { Atom, Setter } from "@rbxts/charm";
+
+type Key = string | number | symbol;
+
+/**
+ * Represents the removal of a value from the state.
+ */
+export interface None {
+ readonly __none: "__none";
+}
+
+export type MaybeNone = undefined extends T ? None : never;
+
+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.
+ */
+export type StateOfMap = {
+ readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
+};
+
+type DataTypes = {
+ [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
+};
+
+type DataType = DataTypes[keyof DataTypes];
+
+/**
+ * A partial patch that can be applied to the state to update it. Represents
+ * the difference between the current state and the next state.
+ *
+ * If a value was removed, it is replaced with `None`. This can be checked
+ * using the `isNone` function.
+ */
+// TODO: Optimize type, this looks overly complex
+export type SyncPatch = MaybeNone | State extends
+ | ReadonlyMap
+ | Map
+ ? ReadonlyMap | None>
+ : State extends Set | ReadonlySet
+ ? ReadonlyMap
+ : State extends readonly (infer T)[]
+ ? FixArrays extends true
+ ? ReadonlyMap | None>
+ : readonly (SyncPatch | None | undefined)[]
+ : State extends DataType
+ ? State
+ : State extends object
+ ? { readonly [P in keyof State]?: SyncPatch }
+ : State;
+
+/**
+ * A payload that can be sent from the server to the client to synchronize
+ * state between the two.
+ */
+export type SyncPayload =
+ | { type: "init"; data: StateOfMap }
+ | { type: "patch"; data: SyncPatch, FixArrays> };
+
+/**
+ * Global configuration options that affect the behavior of Charm Sync.
+ */
+export const config: {
+ /**
+ * 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.
+ *
+ * @server
+ */
+ interval: number;
+ /**
+ * 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.
+ *
+ * @server
+ */
+ preserveHistory: boolean;
+ /**
+ * 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.
+ *
+ * @server
+ */
+ fixArrays: boolean;
+ /**
+ * 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`.
+ *
+ * @server
+ */
+ validatePatches: boolean;
+};
+
+/**
+ * @client
+ */
+export namespace client {
+ /**
+ * 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.
+ */
+ 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 Signals)[]
+ ): void;
+
+ /**
+ * Unsubscribes from all server state updates.
+ */
+ export function removeAllSignals(): void;
+
+ /**
+ * 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.
+ */
+ export function patch(
+ payloads: SyncPayload[],
+ ): void;
+}
+
+/**
+ * @server
+ */
+export namespace server {
+ /**
+ * 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, computed signals, 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.
+ */
+ export function addSignalsToClient(
+ client: Player,
+ signals: Signals,
+ ): void;
+
+ /**
+ * Unsubscribes a client from receiving all state updates. To only unsubscribe
+ * from specific keys, use `removeSignalsFromClient` instead.
+ *
+ * @param client The client receiving state updates.
+ */
+ export function removeClient(client: Player): void;
+
+ /**
+ * 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.
+ */
+ export function removeSignalsFromClient(
+ client: Player,
+ ...keys: (keyof Signals)[]
+ ): void;
+
+ /**
+ * 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.
+ */
+ export function connect(
+ onSync: (client: Player, payloads: SyncPayload[]) => void,
+ ): void;
+
+ /**
+ * Stops syncing state updates to clients at the automatic interval.
+ * Flushing can still be performed manually via `flush`.
+ */
+ export function disconnect(): void;
+
+ /**
+ * 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.
+ */
+ export function flush(): void;
+
+ /**
+ * Unsubscribes all clients, stops tracking all state, and disconnects
+ * from the automatic sync interval.
+ */
+ export function reset(): void;
+}
+
+export namespace patch {
+ /**
+ * 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.
+ */
+ export function nilToNone(value: T | undefined): T | None;
+
+ /**
+ * 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.
+ * @returns A patch that can be applied to the current state.
+ */
+ export function diff(
+ currentState: State,
+ nextState: State,
+ ): SyncPatch;
+
+ /**
+ * Applies a patch to a state and returns the resulting value.
+ *
+ * @param currentState The current state.
+ * @param statePatch The patches to apply.
+ * @returns The new state with the patch applied.
+ */
+ export function applyImmutable(
+ currentState: State,
+ statePatch: SyncPatch,
+ ): State;
+}
+
+type DeepFlattenImpl = {
+ [K in keyof T]: T[K] extends DataType | Callback
+ ? { [P in `${Prefix}${Exclude}`]: T[K] }
+ : T[K] extends object
+ ? DeepFlattenImpl}/`>
+ : { [P in `${Prefix}${Exclude}`]: T[K] };
+}[keyof T];
+
+type DeepFlatten = Reconstruct>>;
+
+/**
+ * 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.
+ */
+export function flatten(input: T): DeepFlatten;
+
+/**
+ * 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.
+ * @returns `true` if the value is `None`, otherwise `false`.
+ */
+export function isNone(value: unknown): value is None;
+
+/**
+ * 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.
+ * @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/init.luau b/packages/charm-sync/src/init.luau
new file mode 100644
index 0000000..53a6353
--- /dev/null
+++ b/packages/charm-sync/src/init.luau
@@ -0,0 +1,19 @@
+local client = require("@self/client")
+local config = require("@self/config")
+local patch = require("@self/patch")
+local server = require("@self/server")
+local types = require("@self/types")
+local utils = require("@self/utils")
+
+export type None = types.None
+export type SyncPayload = types.SyncPayload
+
+return {
+ config = config,
+ client = client,
+ server = server,
+ patch = patch,
+ isNone = patch.isNone,
+ flatten = utils.flatten,
+ signalToAtom = utils.signalToAtom,
+}
diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau
new file mode 100644
index 0000000..d6b940f
--- /dev/null
+++ b/packages/charm-sync/src/patch.luau
@@ -0,0 +1,139 @@
+local Charm = require("../Charm")
+local config = require("./config")
+local types = require("./types")
+local validate = require("./validate")
+
+local flags = Charm.flags
+
+export type None = types.None
+
+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`.
+]]
+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.
+]]
+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 `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
+ -- The object is either not an array or a dense array
+ return object
+ end
+
+ local dictionary = {}
+
+ for index, value in object do
+ dictionary[tostring(index)] = value
+ end
+
+ return dictionary
+end
+
+local function diff(oldState: any, newState: any): any
+ if oldState == newState then
+ return nil
+ elseif newState == nil then
+ return None
+ elseif type(oldState) ~= "table" or type(newState) ~= "table" then
+ return newState
+ end
+
+ local diffs = table.clone(newState)
+
+ 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 oldState[1] ~= nil or newState[1] ~= nil then
+ diffs = stringifySparseArray(diffs)
+ end
+ if flags.strict and config.validatePatches then
+ validate(diffs)
+ end
+ end
+
+ return diffs
+end
+
+local function apply(oldState: any, diffs: any): any
+ if isNone(diffs) then
+ return nil
+ elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
+ return diffs
+ end
+
+ local newState = table.clone(oldState)
+ local stateIsArray = oldState[1] ~= nil
+
+ 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
+ newState[key] = apply(newState[key], value)
+ end
+
+ 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 {
+ isNone = isNone,
+ nilToNone = nilToNone,
+ diff = diff,
+ apply = apply,
+ applyMutable = applyMutable,
+}
diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau
new file mode 100644
index 0000000..6485e18
--- /dev/null
+++ b/packages/charm-sync/src/server.luau
@@ -0,0 +1,416 @@
+local Charm = require("../Charm")
+local config = require("./config")
+local patch = require("./patch")
+local types = require("./types")
+local utils = require("./utils")
+
+type SyncPayload = types.SyncPayload
+
+type SyncStatus = number
+
+type ClientKeys = { [string]: SyncStatus }
+
+type TrackedState = {
+ identifier: unknown,
+ currentValue: T,
+ lastSyncedValue: T,
+ isReactive: boolean,
+ dispose: () -> (),
+}
+
+type StateUpdate = {
+ key: string,
+ newValue: any,
+ oldValue: any,
+}
+
+local PENDING_INITIAL_STATE: SyncStatus = 0
+local LISTENING_FOR_CHANGES: SyncStatus = 1
+
+local trackedStates: { [string]: TrackedState } = {}
+local globalUpdates: { StateUpdate } = {}
+local clients: { [any]: ClientKeys } = {}
+
+local connection: RBXScriptConnection?
+local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
+
+--[[
+ 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 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
+
+ -- 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 = snapshot,
+ oldValue = prevSnapshot,
+ })
+ end
+
+ trackedState.currentValue = rawValue
+ trackedState.isReactive = isReactive
+ end)
+
+ trackedState = {
+ identifier = identifier,
+ currentValue = rawInitialValue,
+ lastSyncedValue = snapshot,
+ isReactive = initialValue ~= rawInitialValue,
+ dispose = dispose,
+ }
+
+ trackedStates[key] = trackedState
+end
+
+local function stopTracking(key: string)
+ local trackedState = trackedStates[key]
+ if trackedState then
+ trackedState.dispose()
+ trackedStates[key] = nil
+ end
+end
+
+local function purgeUntracked()
+ local active: { [string]: boolean } = {}
+
+ for _, clientKeys in clients do
+ for key in clientKeys do
+ active[key] = true
+ end
+ end
+
+ for key in trackedStates do
+ if not active[key] then
+ stopTracking(key)
+ end
+ end
+end
+
+local function diffGlobalUpdateBuffer(): { any }
+ local patchCount = #globalUpdates
+ local patches = table.create(patchCount)
+
+ for index, update in globalUpdates do
+ patches[index] = patch.diff(update.oldValue, update.newValue)
+ end
+
+ return patches
+end
+
+local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
+ local clientKeys = clients[client]
+
+ if not clientKeys then
+ return nil
+ end
+
+ local payloads: { SyncPayload } = {}
+
+ -- Iterate over all global updates and see if the client is interested in
+ -- any of them. If so, we create a patch payload for the update.
+ for index, update in globalUpdates do
+ local patchData = globalPatches[index]
+
+ if patchData == nil then
+ continue
+ end
+
+ local key = update.key
+ local status = clientKeys[key]
+
+ if status == LISTENING_FOR_CHANGES then
+ local payload: SyncPayload = {
+ type = "patch",
+ data = { [key] = patchData },
+ }
+ table.insert(payloads, payload)
+ elseif status == PENDING_INITIAL_STATE then
+ 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 = {}
+
+ for key, status in clientKeys do
+ local trackedState = trackedStates[key]
+
+ if status == PENDING_INITIAL_STATE and trackedState then
+ initData[key] = trackedState.currentValue
+ clientKeys[key] = LISTENING_FOR_CHANGES
+ end
+ end
+
+ if next(initData) ~= nil then
+ table.insert(payloads, 1, { type = "init" :: "init", data = initData })
+ end
+
+ return payloads
+end
+
+local function diffGlobalState(): { [string]: any }?
+ local currentGlobalState: { [string]: any } = {}
+ local previousGlobalState: { [string]: any } = {}
+
+ 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 = currentValue
+ end
+
+ return patch.diff(previousGlobalState, currentGlobalState)
+end
+
+local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
+ local clientKeys = clients[client]
+
+ if not clientKeys then
+ return nil
+ end
+
+ local payloads: { SyncPayload } = {}
+ local initData: { [string]: any } = {}
+ local patchData: { [string]: any } = {}
+
+ -- 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 = trackedStates[key]
+
+ if status == LISTENING_FOR_CHANGES and globalPatch then
+ patchData[key] = globalPatch[key]
+ elseif status == PENDING_INITIAL_STATE then
+ initData[key] = trackedState.currentValue
+ clientKeys[key] = LISTENING_FOR_CHANGES
+ end
+ end
+
+ if next(initData) ~= nil then
+ local payload: SyncPayload = {
+ type = "init",
+ data = initData,
+ }
+ table.insert(payloads, payload)
+ end
+
+ if next(patchData) ~= nil then
+ local payload: SyncPayload = {
+ type = "patch",
+ data = patchData,
+ }
+ table.insert(payloads, payload)
+ end
+
+ return payloads
+end
+
+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, 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 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.
+]]
+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.
+]]
+local function removeSignalsFromClient(client: any, ...: string)
+ local clientKeys = clients[client]
+
+ if not clientKeys then
+ return
+ end
+
+ for index = 1, select("#", ...) do
+ local key = select(index, ...)
+ clientKeys[key] = nil
+ end
+
+ 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.
+]]
+local function flush()
+ if not syncCallback then
+ return
+ end
+
+ local granular = config.preserveHistory
+ local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
+
+ for client in clients do
+ local payloads = if granular
+ then selectFromGlobalPatchList(client, globalPatch :: { any })
+ else selectFromGlobalPatch(client, globalPatch)
+
+ if payloads and payloads[1] then
+ if task then
+ task.spawn(syncCallback, client, payloads)
+ else
+ syncCallback(client, payloads)
+ end
+ end
+ end
+
+ 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.
+]]
+local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
+ syncCallback = onSync
+ startInterval(flush)
+end
+
+--[[
+ 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()
+ connection = nil
+ end
+end
+
+--[[
+ Unsubscribes all clients, stops tracking all state, and disconnects
+ from the automatic sync interval.
+]]
+local function reset()
+ table.clear(trackedStates)
+ table.clear(clients)
+ table.clear(globalUpdates)
+ disconnect()
+end
+
+return {
+ addSignalsToClient = addSignalsToClient,
+ removeClient = removeClient,
+ removeSignalsFromClient = removeSignalsFromClient,
+ connect = connect,
+ disconnect = disconnect,
+ flush = flush,
+ reset = reset,
+}
diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau
new file mode 100644
index 0000000..664ee61
--- /dev/null
+++ b/packages/charm-sync/src/types.luau
@@ -0,0 +1,30 @@
+--[[
+ 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",
+ data: { [string]: any },
+ }
+ | {
+ type: "patch",
+ 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.
+]]
+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
new file mode 100644
index 0000000..4a49871
--- /dev/null
+++ b/packages/charm-sync/src/utils.luau
@@ -0,0 +1,65 @@
+local Charm = require("../Charm")
+
+type Atom = Charm.Atom
+
+--[[
+ 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: { [any]: any }): Flattened
+ local result = {}
+
+ local function visit(node: { [any]: any }, path: string)
+ for key, value in node do
+ local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
+
+ if type(value) == "table" then
+ visit(value, location)
+ else
+ result[location] = value
+ end
+ end
+ end
+
+ visit(input, "")
+
+ 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.
+]]
+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
new file mode 100644
index 0000000..dfba716
--- /dev/null
+++ b/packages/charm-sync/src/validate.luau
@@ -0,0 +1,47 @@
+local function isTableUnsafe(object: { [any]: any })
+ local firstKey = next(object)
+
+ if firstKey == nil then
+ return false
+ end
+
+ 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 type(key) == "number" and key > maxKey then
+ maxKey = key
+ end
+ end
+
+ -- Array keys must be sequential
+ if keyType == "number" and maxKey > #object then
+ return true
+ end
+
+ return false
+end
+
+--[[
+ 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
+ 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\n",
+ value
+ )
+ end
+end
+
+return validate
diff --git a/packages/charm-sync/test/client.test.luau b/packages/charm-sync/test/client.test.luau
new file mode 100644
index 0000000..20c41a8
--- /dev/null
+++ b/packages/charm-sync/test/client.test.luau
@@ -0,0 +1,137 @@
+local Charm = require("../Charm")
+local DeepCharm = require("../../deep-charm/src")
+local client = require("../src/client")
+local test = require("../../../tests/test")
+
+test.afterEach(function()
+ client.removeAllSignals()
+end)
+
+test("should hydrate atom", function()
+ local a = Charm.atom(1)
+
+ client.addSignals({ a = a })
+ client.patch({
+ { type = "init", data = { a = 2 } },
+ })
+
+ assert(a() == 2, "should set a to the initial value")
+end)
+
+test("should merge patch", function()
+ local a = Charm.atom({ b = 1, c = 2 })
+ local previous = a()
+
+ client.addSignals({ a = a })
+ client.patch({
+ { type = "init", data = { a = a() } },
+ })
+
+ assert(a() == previous, "should set a to the initial value")
+
+ client.patch({
+ { type = "patch", data = { a = { b = 3 } } },
+ })
+
+ assert(a() ~= previous, "should apply patch")
+ assert(a().b == 3, "should set b to the new value")
+ 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 })
+
+ client.addSignals({ a = a })
+ client.patch({
+ { type = "init", data = { a = a() } },
+ })
+
+ -- This patch would be the result of a diff between { 1 } and { 1, 2 } that
+ -- was JSON-serialized and deserialized. The client should be able to catch
+ -- this and convert the string key back to a number.
+ client.patch({
+ { type = "patch", data = { a = { ["2"] = 2 } } },
+ })
+
+ assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
+ assert(a()[2] == 2, "should convert serialized arrays to use numbers")
+end)
+
+test("should merge multiple patches", function()
+ local a = Charm.atom(1)
+ local calls = {}
+
+ client.addSignals({ a = a })
+
+ Charm.subscribe(a, function(value)
+ table.insert(calls, value)
+ end)
+
+ client.patch({
+ { type = "init", data = { a = a() } },
+ })
+
+ client.patch({
+ { type = "patch", data = { a = 2 } },
+ { type = "patch", data = { a = 3 } },
+ })
+
+ assert(a() == 3, "should apply the last patch")
+ assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
+end)
+
+test("should batch patches", function()
+ local a = Charm.atom(0)
+ local b = Charm.atom(0)
+ local states = {}
+
+ client.addSignals({ a = a, b = b })
+
+ Charm.subscribe(a, function()
+ states.b = b()
+ end)
+
+ Charm.subscribe(b, function()
+ states.a = a()
+ end)
+
+ client.patch({
+ { type = "init", data = { a = a(), b = b() } },
+ })
+
+ client.patch({
+ { type = "patch", data = { a = 1, b = 2 } },
+ })
+
+ assert(a() == 1, "should set a to the new value")
+ assert(b() == 2, "should set b to the new value")
+ assert(states.a == 1, "sub to a should run after b updates")
+ assert(states.b == 2, "sub to b should run after a updates")
+end)
+
+return {}
diff --git a/packages/charm-sync/test/e2e.test.luau b/packages/charm-sync/test/e2e.test.luau
new file mode 100644
index 0000000..62ca4a7
--- /dev/null
+++ b/packages/charm-sync/test/e2e.test.luau
@@ -0,0 +1,97 @@
+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")
+
+test.afterEach(function()
+ server.reset()
+ client.removeAllSignals()
+ config.preserveHistory = false
+end)
+
+test("should replicate server state to client", function()
+ local target = {}
+ local serverA = Charm.atom({ count = 0 })
+ local serverB = DeepCharm.reactive({ count = 0 })
+ local clientA = Charm.atom({ count = -1 })
+ local clientB = DeepCharm.reactive({ count = -1 })
+
+ client.addSignals({ a = clientA, b = clientB })
+ server.addSignalsToClient(target, { a = serverA, b = serverB })
+ server.connect(function(syncTarget, payloads)
+ if syncTarget == target then
+ client.patch(payloads)
+ end
+ end)
+
+ server.flush()
+ assert(clientA().count == 0, "Client atom A should be initialized to 0")
+ assert(clientB.count == 0, "Client reactive B should be initialized to 0")
+
+ serverA({ count = 1 })
+ serverB.count = 1
+ server.flush()
+ assert(clientA().count == 1, "Client atom A 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)
+
+ serverA({ count = 2 })
+ server.flush()
+ assert(clientA().count == 2, "Client atom A should be updated to 2")
+ 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 = DeepCharm.reactive({ count = 0 })
+ local clientA = Charm.atom({ count = -1 })
+ local clientB = DeepCharm.reactive({ count = -1 })
+
+ config.preserveHistory = true
+
+ client.addSignals({ a = clientA, b = clientB })
+ server.addSignalsToClient(target, { a = serverA, b = serverB })
+ server.connect(function(syncTarget, payloads)
+ if syncTarget == target then
+ client.patch(payloads)
+ end
+ end)
+
+ local expectedA = { -1, 0, 1, 2, 3, 4 }
+ local expectedB = { -1, 0, 1, 2 }
+
+ Charm.effect(function()
+ local expected = table.remove(expectedA, 1)
+ 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 reactive B should be {expected}`)
+ end)
+
+ server.flush()
+
+ serverA({ count = 1 })
+ serverA({ 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 reactive B should be updated to 2")
+
+ serverA({ count = 3 })
+ serverA({ count = 4 })
+ server.flush()
+ assert(clientA().count == 4, "Client atom A should be updated to 4")
+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/test/patch.test.luau b/packages/charm-sync/test/patch.test.luau
new file mode 100644
index 0000000..076544e
--- /dev/null
+++ b/packages/charm-sync/test/patch.test.luau
@@ -0,0 +1,133 @@
+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
+ local copy = {}
+ for index = 1, #object do
+ copy[index] = object[index]
+ end
+ return copy
+ elseif table.maxn(object) ~= #object then
+ local copy = {}
+ for key, value in object do
+ copy[tostring(key)] = value
+ end
+ return copy
+ else
+ return object
+ end
+end
+
+test.afterEach(function()
+ config.fixArrays = true
+ config.validatePatches = true
+end)
+
+test("returns a patch", function()
+ local prevState = { foo = "bar", baz = "qux" }
+ local nextState = { foo = "bar", baz = "quux" }
+ local diffs = patch.diff(prevState, nextState)
+
+ 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 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")
+ assert(applied.foo == "bar", "should keep the unchanged value")
+ assert(applied.qaz == "qux", "should add the new value")
+end)
+
+test("allows nested diffs", function()
+ local prevState = { foo = { bar = "baz" } }
+ local nextState = { foo = { bar = "qux" } }
+ local diffs = patch.diff(prevState, nextState)
+ local applied = patch.apply(prevState, diffs)
+
+ assert(applied.foo.bar == "qux", "should apply the nested patch")
+end)
+
+test("preserves array density when applying", function()
+ local prevState = { "a", "b", "c", "d" }
+ local nextState = { ["2"] = "B", ["4"] = "D" }
+ local applied = patch.apply(prevState, nextState)
+
+ assert(applied[1] == "a", "should keep a")
+ assert(applied[2] == "B", "should apply B")
+ assert(applied[3] == "c", "should keep c")
+ assert(applied[4] == "D", "should apply D")
+
+ assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
+ assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
+end)
+
+test("preserves array density after serdes", function()
+ -- This particular change results in a patch that cannot be serialized
+ -- without losing information. Charm resolves this issue internally.
+ local prevState = { "a", "b", "c", "d", "e" }
+ local nextState = { "A", "b", "c", "D" }
+
+ 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]}`)
+ end
+
+ for index in applied do
+ assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
+ end
+end)
+
+test("cannot preserve sparse arrays", function()
+ config.validatePatches = false
+
+ local prevState = {}
+ local nextState = { [2] = "foo" }
+
+ local diffs = mockArraySerdes(patch.diff(prevState, nextState))
+ local applied = patch.apply(prevState, diffs)
+
+ -- 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")
+end)
+
+test("optionally fixes sparse arrays", function()
+ config.fixArrays = true
+
+ for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
+ assert(type(index) == "string", `expected {type(index)} {index} to be string`)
+ end
+
+ config.fixArrays = false
+
+ for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
+ assert(type(index) == "number", `expected {type(index)} {index} to be number`)
+ end
+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)
+
+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/test/server.test.luau b/packages/charm-sync/test/server.test.luau
new file mode 100644
index 0000000..d24970c
--- /dev/null
+++ b/packages/charm-sync/test/server.test.luau
@@ -0,0 +1,456 @@
+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")
+
+test.afterEach(function()
+ server.reset()
+end)
+
+test("should replicate state changes", function()
+ local client = {}
+ local a = Charm.atom(0)
+ local b = Charm.atom(0)
+ local c = Charm.atom(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 == a(), "Expected init payload to contain atom 'a'")
+ assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
+ assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
+
+ a(1)
+ b(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 == a(), "Expected patch payload to contain atom 'a'")
+ assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
+end)
+
+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: unknown, payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a, b = b })
+ 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 == a().x, "Expected init payload to contain atom 'a.x'")
+ assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
+ assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
+
+ a({ x = 1, y = 0 })
+ b(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 == 1, "Expected patch payload to contain atom 'a.x'")
+ assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
+ 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: { types.SyncPayload }, payloadsB: { types.SyncPayload }
+
+ server.addSignalsToClient(clientA, { a = a })
+ server.connect(function(target, payloads)
+ if target == clientA then
+ payloadsA = payloads
+ elseif target == clientB then
+ payloadsB = payloads
+ end
+ end)
+
+ a(1)
+ server.flush()
+ 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")
+
+ 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")
+ assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
+ assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
+end)
+
+test("should init new atoms", function()
+ local client = {}
+ local a = Charm.atom(0)
+ local b = Charm.atom(0)
+ local target: unknown, payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a })
+ server.connect(function(...)
+ target, payloads = ...
+ end)
+
+ server.flush()
+ server.addSignalsToClient(client, { b = b })
+ a(1)
+ b(1)
+ 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.b == b(), "Expected init payload to contain atom 'b'")
+ assert(payloads[2].type == "patch", "Expected second payload to be patch")
+ assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
+ assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
+end)
+
+test("should only replicate to synced clients", function()
+ local clientA = {}
+ local clientB = {}
+ local a = Charm.atom(0)
+ local b = Charm.atom(0)
+ local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
+
+ server.addSignalsToClient(clientA, { a = a })
+ server.addSignalsToClient(clientB, { b = b })
+ server.connect(function(target, payloads)
+ if target == clientA then
+ payloadsA = payloads
+ elseif target == clientB then
+ payloadsB = payloads
+ end
+ end)
+
+ server.flush()
+ 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")
+ assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A")
+ assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
+ assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
+ assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
+
+ payloadsA = {}
+ payloadsB = {}
+ a(1)
+ 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")
+ assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A")
+ assert(#payloadsB == 0, "Expected no payloads for client B")
+
+ payloadsA = {}
+ payloadsB = {}
+ b(1)
+ server.flush()
+ assert(#payloadsA == 0, "Expected no payloads for client A")
+ assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
+ assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
+ assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
+end)
+
+test("should serialize state", function()
+ local client = {}
+ local a = Charm.atom({ 1, 2, 3, 4, 5 })
+ local payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a })
+ server.connect(function(_, p)
+ payloads = p
+ end)
+ server.flush()
+
+ a({ 1, 2, 0, 4 })
+ server.flush()
+ assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
+ assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
+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.describe("historical", function()
+ test.beforeEach(function()
+ config.preserveHistory = true
+ end)
+
+ test.afterEach(function()
+ config.preserveHistory = false
+ end)
+
+ test("should replicate granular state changes", function()
+ local client = {}
+ local a = Charm.atom(0)
+ local b = Charm.atom(0)
+ local c = Charm.atom(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 == 0, "Expected init payload to contain atom 'a'")
+ assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
+ assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
+
+ a(1)
+ a(2)
+ b(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 == 1, "Expected patch payload to contain atom 'a'")
+ assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
+ 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: { types.SyncPayload }, payloadsB: { types.SyncPayload }
+
+ server.addSignalsToClient(clientA, { a = a })
+ server.connect(function(target, payloads)
+ if target == clientA then
+ payloadsA = payloads
+ elseif target == clientB then
+ payloadsB = payloads
+ end
+ end)
+
+ a(1)
+ a(2)
+ server.flush()
+ assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
+ assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
+ assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
+ assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
+
+ server.addSignalsToClient(clientB, { a = a })
+ a(3)
+ a(4)
+ server.flush()
+ assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
+ assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
+ assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
+ assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
+ assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
+ assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
+ assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
+ end)
+
+ test("should init new atoms", function()
+ local client = {}
+ local a = Charm.atom(0)
+ local b = Charm.atom(0)
+ local payloads: { types.SyncPayload }
+
+ server.addSignalsToClient(client, { a = a })
+ server.connect(function(_, ...)
+ payloads = ...
+ 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'")
+
+ server.addSignalsToClient(client, { b = b })
+ a(1)
+ a(2)
+ b(1)
+ b(2)
+ server.flush()
+ assert(payloads[1].type == "patch", "Expected first payload to be patch")
+ assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
+ assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
+ assert(payloads[3].type == "init", "Expected third payload to be init")
+ assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
+ 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/test/utils.test.luau b/packages/charm-sync/test/utils.test.luau
new file mode 100644
index 0000000..1d50f55
--- /dev/null
+++ b/packages/charm-sync/test/utils.test.luau
@@ -0,0 +1,22 @@
+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)) } },
+ }
+
+ 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")
+ assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
+
+ assert(result.b == nil, "should remove the original nested tables")
+ assert(result.d == nil, "should remove the original nested tables")
+end)
+
+return {}
diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml
new file mode 100644
index 0000000..589e7ed
--- /dev/null
+++ b/packages/charm-sync/wally.toml
@@ -0,0 +1,15 @@
+[package]
+name = "littensy/charm-sync"
+version = "0.4.1"
+registry = "https://github.com/UpliftGames/wally-index"
+realm = "shared"
+exclude = [
+ "node_modules",
+ "test",
+ "src/index.d.ts",
+ "package.json",
+ "Charm.luau",
+]
+
+[dependencies]
+Charm = "littensy/charm@^0.11.0"
diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json
new file mode 100644
index 0000000..a485e4d
--- /dev/null
+++ b/packages/charm/default.project.json
@@ -0,0 +1,6 @@
+{
+ "name": "charm",
+ "tree": {
+ "$path": "src"
+ }
+}
diff --git a/packages/charm/package.json b/packages/charm/package.json
new file mode 100644
index 0000000..7ef2e29
--- /dev/null
+++ b/packages/charm/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@rbxts/charm",
+ "version": "0.11.1",
+ "description": "Atomic state management based on alien-signals",
+ "license": "MIT",
+ "main": "src/init.luau",
+ "types": "src/index.d.ts",
+ "files": [
+ "src",
+ "*.luau",
+ "!wally.toml",
+ "!wally.lock",
+ "!default.project.json",
+ "!test"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/littensy/charm.git"
+ },
+ "scripts": {
+ "prepare": "cp ../../LICENSE . && cp ../../README.md .",
+ "publish:wally": "wally publish"
+ }
+}
diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts
new file mode 100644
index 0000000..e4689e5
--- /dev/null
+++ b/packages/charm/src/index.d.ts
@@ -0,0 +1,280 @@
+type Key = string | number | symbol;
+
+type Cleanup = () => void;
+
+export enum ReactiveFlags {
+ None = 0b000000,
+ Mutable = 0b000001,
+ Watching = 0b000010,
+ RecursedCheck = 0b000100,
+ Recursed = 0b001000,
+ Dirty = 0b010000,
+ Pending = 0b100000,
+}
+
+export interface ReactiveNode {
+ deps?: Link;
+ depsTail?: Link;
+ subs?: Link;
+ subsTail?: Link;
+ flags: ReactiveFlags;
+}
+
+export interface Link {
+ version: number;
+ dep: ReactiveNode;
+ sub: ReactiveNode;
+ prevSub?: Link;
+ nextSub?: Link;
+ prevDep?: Link;
+ nextDep?: Link;
+}
+
+export interface Atom {
+ (update: Update): 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;
+
+/**
+ * Global flags that modify the behavior of Charm's reactive system.
+ */
+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 by default in Roblox Studio.
+ */
+ strict: boolean;
+ /**
+ * Enforces immutability of tables by deep-freezing table values. Enabled
+ * by default in Roblox Studio.
+ */
+ frozen: boolean;
+ /**
+ * Whether parent effects should track inner effects and clean them up
+ * when the parent effect re-runs. This is usually desirable, but can be
+ * disabled to revert to the old behavior. Defaults to `true`.
+ */
+ trackInnerEffects: boolean;
+};
+
+/**
+ * Creates a reactive signal that stores a value and notifies subscribers when
+ * the value changes. The signal provides both a getter and a setter.
+ *
+ * @param initialValue The initial value of the signal.
+ * @param equals A comparator function to determine if the signal's value has changed.
+ * @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: Getter, setter: Setter]>;
+export function signal(): LuaTuple<[getter: Getter, setter: Setter]>;
+
+/**
+ * Creates a reactive atom that acts as both a signal getter and setter.
+ * Calling the atom with an argument sets its value, while calling it with no
+ * arguments returns its current value.
+ *
+ * @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
+ * @param equals A comparator function to determine if the atom's value has changed.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
+ */
+export function atom(initialValue: T, equals?: Equals): Atom;
+export function atom(): Atom;
+
+/**
+ * Creates a new read-only signal that is computed based on the values of
+ * other signals. The computed signal's value is automatically updated when a
+ * change is detected in any of its dependencies.
+ *
+ * @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): Getter;
+
+/**
+ * Creates an effect that runs a callback in response to signal state changes.
+ * An effect tracks which signals are accessed during its execution, and runs
+ * the callback when those signals change.
+ *
+ * The effect callback may return a cleanup function, which gets called once,
+ * either before the effect re-runs or when it is disposed.
+ *
+ * If the effect is called within another effect, it will be disposed when the
+ * parent effect re-runs.
+ *
+ * @param fn The function to run when dependencies change.
+ * @returns A function for disposing the effect.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
+ */
+export function effect(fn: () => Cleanup | void): Cleanup;
+
+/**
+ * Creates an effect scope that can capture reactive effects and computed
+ * signals created within it so that they can be disposed together.
+ *
+ * @param fn A function that may create reactive effects.
+ * @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
+ * @returns A function for disposing all effects created within the scope.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
+ */
+export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup;
+
+/**
+ * Allows you to manually trigger updates for downstream dependencies when
+ * you've directly mutated a signal's value without using the signal setter.
+ *
+ * @param fn The dependency or a function that calls multiple dependencies.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
+ */
+export function trigger(fn: () => void): void;
+
+/**
+ * Binds the cleanup function to the current active effect or effect scope. If
+ * there is no active effect, a warning is logged, unless `failSilently` is
+ * set to `true`.
+ *
+ * @param fn The cleanup function to register.
+ * @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
+ */
+export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
+
+/**
+ * Runs the function without subscribing to signal updates and prevents the
+ * current effect or scope from tracking inner effects created within the
+ * function.
+ *
+ * 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.
+ * @returns The return value of the function.
+ * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
+ */
+export function untracked(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.
+ *
+ * @param fn A function that performs multiple updates.
+ * @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#batchcallback
+ */
+export function batch(fn: (...args: Args) => Result, ...args: Args): Result;
+
+/**
+ * Creates an effect that runs the callback one immediately, and then again
+ * when the value returned by the getter changes. The callback receives the new
+ * value and the previous value as arguments.
+ *
+ * @param getter A function that returns the value to watch.
+ * @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: 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.
+ *
+ * @param getter A function that returns the value to watch.
+ * @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: Getter, callback: (value: T, previousValue: T) => void): Cleanup;
+
+/**
+ * Creates a new read-only signal that is computed by mapping over the entries
+ * of the source table. If the key is omitted from the transform function's
+ * return value, the original key is preserved.
+ *
+ * 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: Getter