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 6313b56..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1 +0,0 @@
-* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4369391..af91488 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,31 +1,24 @@
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
+ with:
+ submodules: true
- uses: pnpm/action-setup@v4
with:
run_install: true
-
- - name: Install Rokit
- uses: CompeyDev/setup-rokit@v0.1.2
-
- - name: Install Wally dependencies
- run: pnpm wally
+ - uses: CompeyDev/setup-rokit@v0.1.2
- name: Check code quality
- run: pnpm check
+ run: sh bin/check.sh
+
+ - name: Run tests
+ run: lune run test/spec.server.luau
- name: Build models
- run: pnpm build
+ run: sh bin/build.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 07b75ad..5bdee21 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,13 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
-
- - name: Install Rokit
- uses: CompeyDev/setup-rokit@v0.1.2
+ - uses: CompeyDev/setup-rokit@v0.1.2
- name: Build models
- run: pnpm build
+ run: sh bin/build.sh
- name: Upload release assets
uses: AButler/upload-release-assets@v3.0
diff --git a/.gitignore b/.gitignore
index f3a27c4..52e0af1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,13 @@
/.vscode
-/node_modules
+/.pesde
/build
-/modules/DevPackages
-/packages/**/node_modules
/packages/**/wally.lock
-/packages/**/README.md
/packages/**/LICENSE.md
+/packages/**/README.md
/sourcemap.json
+node_modules
*.tsbuildinfo
-*.tgz
*.rbxm
+*.tgz
.DS_Store
+roblox.d.luau
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e1cba9d
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "test/suite"]
+ path = test/suite
+ url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
diff --git a/.luaurc b/.luaurc
index e2b625c..4c8043d 100644
--- a/.luaurc
+++ b/.luaurc
@@ -1,3 +1,6 @@
{
- "languageMode": "strict"
+ "languageMode": "strict",
+ "lint": {
+ "LocalShadow": false
+ }
}
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..b644580
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+public-hoist-pattern[]=*@rbxts*
diff --git a/README.md b/README.md
index 8ea132b..24a753c 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-
+
Charm
- Atomic state management for Roblox.
+ Manage state with reactive signals
npm package →
@@ -18,25 +18,128 @@
-**Charm** is an atomic and immutable state management library, inspired by [Jotai](https://jotai.org) and [Nanostores](https://github.com/nanostores/nanostores). Store your state in atoms, and write your own functions to read, write, and observe state.
+**Charm** is a Roblox state management library inspired by the [reactive signals](https://preactjs.com/blog/introducing-signals/) from libraries like Preact and Solid. It's built on [alien-signals](https://github.com/stackblitz/alien-signals), modified for a wide range of use cases: from rendering user interfaces to handling game logic.
-See an example of Charm's features in [this example repository](https://github.com/littensy/charm-example).
+**Charm works under a few core principles.**
-## 🍀 Features
+- Manage state with reactive signals: state containers that hold a value
+- React to state updates: reading a signal automatically subscribes to it
+- Combine multiple signals: derive new values from existing state that stay up-to-date
+- Fine-grained reactivity: make targeted updates in response to specific changes in the state
+- Data should be immutable: values are compared directly (`==`) to optimize change detection
-- ⚛️ **Manage state with _atoms_.** Decompose state into independent containers called _atoms_, as opposed to combining them into a single store.
+**Want to learn more about signals?**
-- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state.
+- https://preactjs.com/blog/introducing-signals
+- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
+- https://angular.dev/guide/signals
-- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness.
+
+Table of Contents
-- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you.
+- [Installation](#installation)
+- [Reference](#reference)
+ - [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
+ - [`computed(getter)`](#computedgetter)
+ - [`effect(callback)`](#effectcallback)
+ - [`untracked(callback)`](#untrackedcallback)
+ - [`peek(callback)`](#peekcallback)
+ - [`batch(callback)`](#batchcallback)
+ - [`effectScope(callback)`](#effectscopecallback)
+ - [`listen(getter, callback)`](#listengetter-callback)
+ - [`subscribe(getter, callback)`](#subscribegetter-callback)
+ - [`observe(getter, callback)`](#observegetter-callback)
+ - [`mapped(getter, mapper)`](#mappedgetter-mapper)
+ - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
+ - [`atom(initialValue, equals?)`](#atominitialvalue-equals)
+ - [`recursive()`](#recursive)
+ - [`trigger(callback)`](#triggercallback)
+ - [`reactive(initialValue)`](#reactiveinitialvalue)
+ - [`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)
+- [Migration](#migration)
+- [Examples](#examples)
----
+
-## 📦 Setup
+## At a Glance
-Install Charm for roblox-ts using your package manager of choice.
+```luau
+local getTodos, setTodos = signal({} :: { [number]: string })
+local getQuery, setQuery = signal("")
+
+observe(getTodos, function(todo, index)
+ local instance = Instance.new("TextLabel")
+
+ local getText = computed(function(prevText)
+ return getTodos()[index] or prevText
+ end)
+
+ effect(function()
+ instance.Text = getText()
+ instance.Visible = getText():match(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
+local getTodos, setTodos = signal({} :: { [number]: string })
+local getQuery, setQuery = signal("")
+
+-- Create a text label when an item is added to the list
+observe(getTodos, function(todo, index)
+ local instance = Instance.new("TextLabel")
+
+ -- Create a computed signal that stores the current text, or the previous
+ -- value if the item was removed
+ local getText = computed(function(prevText)
+ return getTodos()[index] or prevText
+ end)
+
+ -- Set properties when the text or query updates
+ effect(function()
+ instance.Text = getText()
+ instance.Visible = getText():match(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 starting with "Buy"
+setQuery("^Buy")
+```
+
+
+
+## Installation
```sh
npm install @rbxts/charm
@@ -44,8 +147,6 @@ yarn add @rbxts/charm
pnpm add @rbxts/charm
```
-Alternatively, add `littensy/charm` to your `wally.toml` file.
-
```toml
[dependencies]
Charm = "littensy/charm@VERSION"
@@ -53,382 +154,510 @@ 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` before importing Charm.
+### `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 selectors, subscriptions, and batched functions:
+```luau
+local getCounter, setCounter = signal(0)
- - Errors provide the function's name and line number.
- - Yielding in certain functions will throw an error.
+print(getCounter()) -- 0
+setCounter(1)
+setCounter(function(count)
+ return count + 1
+end)
+```
-- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client.
+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.
-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.
+You can also pass a custom equality function to only update the signal if the equality function returns `false`:
+
+```luau
+local getMax, setMax = signal(0, function(current, incoming)
+ return incoming <= current
+end)
+
+setMax(1) -- 1
+setMax(2) -- 2
+setMax(-1) -- 2
+```
+
+> [!NOTE]
+> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a combined getter and setter.
---
-## 📚 Reference
+### `computed(getter)`
-### `atom(state, options?)`
+The `computed` function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value.
-Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be [subscribed](#subscribecallback-listener) to.
-
-Call `atom` to create a state container initialized with the value `state`.
+The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value.
```luau
-local nameAtom = atom("John")
-local todosAtom: Atom<{ string }> = atom({})
-```
-
-#### Parameters
-
-- `state`: The value to assign to the atom initially.
-
-- **optional** `options`: An object that configures the behavior of this atom.
-
- - **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.
-
-```luau
-local function newTodo()
- nameAtom("Jane")
-
- todosAtom(function(todos)
- todos = table.clone(todos)
- table.insert(todos, "Buy milk")
- return todos
- end)
-
- print(nameAtom()) --> "Jane"
-end
-```
-
----
-
-### `subscribe(callback, listener)`
-
-Call `subscribe` to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state.
-
-```luau
-local nameAtom = atom("John")
-
-local cleanup = subscribe(nameAtom, function(name, prevName)
- print(name)
+local getName, setName = signal("John")
+local getSurname, setSurname = signal("Doe")
+local getFullName = computed(function()
+ return `{getName()} {getSurname()}`
end)
-nameAtom("Jane") --> "Jane"
+print(getFullName()) -- "John Doe"
+setName("Jane")
+print(getFullName()) -- "Jane Doe"
```
-You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update.
+The getter function also receives the previous state (or `nil` if running for the first time). You can use this for more complex state:
```luau
-local function getUppercase()
- return string.upper(nameAtom())
-end
-
-local cleanup = subscribe(getUppercase, function(name)
- print(name)
+local getCounter, setCounter = signal(10)
+local getMax = computed(function(prevMax)
+ return math.max(getCounter(), prevMax or 0)
end)
-nameAtom("Jane") --> "JANE"
+print(getMax()) -- 10
+setCounter(5)
+print(getMax()) -- 10
```
-#### Parameters
-
-- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom.
-
-- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments.
-
-#### Returns
-
-`subscribe` returns a cleanup function.
+Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called _and_ if a dependency updated since the last call.
---
### `effect(callback)`
-Call `effect` to track state changes in all atoms read within the callback. The callback will run 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.
+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.
```luau
-local nameAtom = atom("John")
+local getCounter, setCounter = signal(0)
-local cleanup = effect(function()
- print(nameAtom())
+-- Count is 0
+effect(function()
+ print(`Count is {getCounter()}`)
+end)
+
+setCounter(1) -- Count is 1
+```
+
+You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed:
+
+```luau
+local getCounter, setCounter = signal(0)
+
+local dispose = effect(function()
+ local count = getCounter()
return function()
- print("Cleanup function called!")
+ print(`Cleanup {count}`)
end
end)
+
+setCounter(1) -- Cleanup 0
+dispose() -- Cleanup 1
```
-Because `effect` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies.
-
-#### Parameters
-
-- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change.
-
-#### Returns
-
-`effect` returns a cleanup function.
-
-#### Caveats
-
-- **If your effect should disconnect itself, use the `cleanup` argument.** Because effects run immediately, your effect may run before a `cleanup` function is returned. To disconnect an effect from the inside, use the argument passed to your effect instead:
- ```lua
- effect(function(cleanup)
- if condition() then
- cleanup()
- end
- end)
- ```
-
----
-
-### `computed(callback, options?)`
-
-Call `computed` when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change.
+Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`.
```luau
-local todosAtom: Atom<{ string }> = atom({})
-local mapToUppercase = computed(function()
- local result = table.clone(todosAtom())
- for key, todo in result do
- result[key] = string.upper(todo)
- end
- return result
-end)
-```
-
-Because `computed` implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use [`peek`](#peekvalue) to read from atoms without tracking them as dependencies.
-
-This function is also useful for optimizing `effect` calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using `computed` can group these dependencies together and avoid re-running side effects.
-
-#### Parameters
-
-- `callback`: A function that returns a new value depending on one or more atoms.
-
-- **optional** [`options`](#parameters): An object that configures the behavior of this atom.
-
-#### Returns
-
-`computed` returns a read-only atom.
-
----
-
-### `observe(callback, factory)`
-
-Call `observe` to create an instance of `factory` for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up.
-
-> [!NOTE]
-> Because `observe` tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using [`mapped`](#mappedcallback-mapper) to transform it into a keyed object before passing it to `observe`.
-
-```luau
-local todosAtom: Atom<{ [string]: Todo }> = atom({})
-
-local cleanup = observe(todosAtom, function(todo, key)
- print(`Added {key}: {todo.name}`)
- return function()
- print(`Removed {key}`)
- end
-end)
-```
-
-#### Parameters
-
-- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value.
-
-- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function.
-
-#### Returns
-
-`observe` returns a cleanup function.
-
----
-
-### `mapped(callback, mapper)`
-
-Call `mapped` to transform the keys and values of your state. The `mapper` function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom.
-
-```luau
-local todosAtom: Atom<{ Todo }> = atom({})
-local todosById = mapped(todosAtom, function(todo, index)
- return todo, todo.id
-end)
-```
-
-#### Parameters
-
-- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms.
-
-- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key:
-
- 1. Return a single value to map the table's original key to a new value.
- 2. Return two values, the first being the value and the second being the key, to update both keys and values.
- 3. Return `nil` for the value to remove the key from the resulting table.
-
-#### Returns
-
-`mapped` returns a read-only atom.
-
----
-
-### `peek(value, ...)`
-
-Call `peek` to call a function without tracking it as the dependency of an effect or a selector function.
-
-```luau
-local nameAtom = atom("John")
-local ageAtom = atom(25)
+local getCounter, setCounter = signal(0)
effect(function()
- local name = nameAtom()
- local age = peek(ageAtom)
+ print(`Outer: {getCounter()}`)
+ effect(function()
+ print(`Inner: {getcounter()}`)
+ end)
end)
+
+setCounter(1) -- Outer: 1, Inner: 1
+setCounter(2) -- Outer: 2, Inner: 2
```
-#### Parameters
+> [!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.
-- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result.
+---
-- **optional** `...args`: Additional arguments to pass to the value if it is a function.
+### `untracked(callback)`
-#### Returns
+In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked.
-`peek` returns the result of the given function. If the value is not a function, it will return the value as-is.
+```luau
+local getTracked, setTracked = signal(0)
+local getUntracked, setUntracked = signal(0)
+
+-- Tracked: 0, Untracked: 0
+effect(function()
+ print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
+end)
+
+setTracked(1) -- Tracked: 1, Untracked: 0
+setUntracked(1) -- No output
+setTracked(2) -- Tracked: 2, Untracked: 1
+```
+
+Because `untracked()` executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect:
+
+```luau
+local stopEffect
+local stopScope = effectScope(function()
+ untracked(function()
+ stopEffect = effect(function()
+ return function()
+ print("Cleaned up untracked effect")
+ end
+ end)
+ end)
+end)
+
+stopScope() -- No output, the scope did not track the effect
+stopEffect() -- Cleaned up untracked effect
+```
+
+---
+
+### `peek(callback)`
+
+Similar to `untracked()`, but does not prevent the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked.
+
+```luau
+local getCounter, setCounter = signal(0)
+
+-- Count is 0
+local disposeOuter = effect(function()
+ print(`Count is {peek(getCounter)}`)
+end)
+
+setCounter(1) -- No output; count was accessed in peek()
+```
---
### `batch(callback)`
-Call `batch` to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once.
+Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
```luau
-local nameAtom = atom("John")
-local ageAtom = atom(25)
+local getName, setName = signal("John")
+local getSurname, setSurname = signal("Doe")
+effect(function()
+ print(`Full name: {getName()} {getSurname()}`)
+end)
+
+-- Combines both writes into a single update.
+-- Once the callback completes, outputs "Full name: Foo Bar"
batch(function()
- nameAtom("Jane")
- ageAtom(26)
+ setName("Foo")
+ setSurname("Bar")
end)
```
-#### Parameters
-
-- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied.
-
-#### Returns
-
-`batch` does not return anything.
-
---
-## 📦 React
+### `effectScope(callback)`
-### Setup
-
-Install the React bindings for Charm using your package manager of choice.
-
-```sh
-npm install @rbxts/react-charm
-yarn add @rbxts/react-charm
-pnpm add @rbxts/react-charm
-```
-
-```toml
-[dependencies]
-ReactCharm = "littensy/react-charm@VERSION"
-```
-
----
-
-### `useAtom(callback, dependencies?)`
-
-Call `useAtom` at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes.
+Scopes allow you to dispose multiple effects at once. The `effectScope` function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes.
```luau
-local todosAtom = require(script.Parent.todosAtom)
+local getCounter, setCounter = signal(0)
-local function Todos()
- local todos = useAtom(todosAtom)
- -- ...
-end
+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
```
-If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes.
+Similar to `effect()`, the callback can return a cleanup function that runs when the effect scope is disposed.
+
+---
+
+### `listen(getter, callback)`
+
+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.
+
+The callback also receives the previous value, or `nil` when running for the first time.
```luau
-local todos = useAtom(function()
- return searchTodos(props.filter)
-end, { props.filter })
+local getCounter, setCounter = signal(0)
+
+-- Count is 0 (was nil)
+listen(getCounter, function(count, prevCount)
+ print(`Count is {count} (was {prevCount})`)
+end)
+
+setCounter(1) -- Count is 1 (was 0)
```
-#### Parameters
-
-- `callback`: An atom or selector function that depends on an atom.
-
-- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state.
-
-#### Returns
-
-`useAtom` returns the current state of the atom.
+Note that nested effects can be created inside `listen`, and they will clean up automatically when the listener re-runs or gets disposed.
---
-## 📦 Vide
+### `subscribe(getter, callback)`
-### Setup
-
-Install the Vide bindings for Charm using your package manager of choice.
-
-```sh
-npm install @rbxts/vide-charm
-yarn add @rbxts/vide-charm
-pnpm add @rbxts/vide-charm
-```
-
-```toml
-[dependencies]
-VideCharm = "littensy/vide-charm@VERSION"
-```
-
----
-
-### `useAtom(callback)`
-
-Call `useAtom` in any scope to create a Vide source that returns the current state of an atom or selector.
+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 todosAtom = require(script.Parent.todosAtom)
+local getCounter, setCounter = signal(0)
-local function Todos()
- local todos = useAtom(todosAtom)
- -- ...
-end
+-- Does not output anything initially
+subscribe(getCounter, function(count, prevCount)
+ print(`Count is {count} (was {prevCount})`)
+end)
+
+setCounter(1) -- Count is 1 (was 0)
```
-#### Parameters
-
-- `callback`: An atom or selector function that depends on an atom.
-
-#### Returns
-
-`useAtom` returns a Vide source.
+Note that nested effects can be created inside `subscribe`, and they will clean up automatically when the subscription re-runs or gets disposed.
---
-## 📗 Charm Sync
+### `observe(getter, callback)`
-### Setup
+[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.
-The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice.
+```luau
+local getItems, setItems = signal({ a = 0, b = 0 })
+
+-- Added a, Added b
+observe(getItems, function(value, key)
+ print(`Added {key}`)
+ return function()
+ print(`Removed {key}`)
+ end
+end)
+
+setItems({ a = 0, c = 0 }) -- Removed b, Added c
+```
+
+The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:
+
+```luau
+local getItems, setItems = signal({ a = 0, b = 0 })
+
+local dispose = observe(getItems, function(value, key)
+ local getValue = computed(function(prevValue)
+ return getItems()[key] or prevValue
+ end)
+
+ effect(function()
+ local value = getValue()
+ print(`{key} = {value}`)
+ return function()
+ print(`Cleanup {key} = {value}`)
+ end
+ end)
+end)
+
+setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1
+setItems({ a = 1 }) -- Cleanup b = 0
+dispose() -- Cleanup a = 1
+```
+
+---
+
+### `mapped(getter, mapper)`
+
+The `mapped` function iterates over every key in a table and uses the mapper to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value.
+
+The first value returned by the mapper is used as the new value:
+
+```luau
+local getList, setList = signal({ "a", "b", "c" })
+
+local getUppercase = mapped(getList, function(value)
+ return string.upper(value)
+end)
+
+print(getUppercase()) -- { "A", "B", "C" }
+```
+
+If the mapper 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.
+
+If there is no active effect, a warning is logged, unless `failSilently` is set to `true`.
+
+```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
+```
+
+---
+
+### `recursive()`
+
+By design, Charm's reactive system [does not allow recursion](https://github.com/stackblitz/alien-signals/issues/90#issuecomment-3489711800) in effects or computed signals. In case you need to opt out of recursion checks for an effect, you can call `recursive()` at the top of the effect callback:
+
+```luau
+local getCounter, setCounter = signal(0)
+
+effect(function()
+ recursive()
+ print(getCounter())
+ setCounter(function(count)
+ return math.min(count + 1, 3)
+ end)
+end)
+
+-- Output: 0, 1, 2, 3
+```
+
+---
+
+### `trigger(callback)`
+
+The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter:
+
+```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
+```
+
+---
+
+### `reactive(initialValue)`
+
+The `reactive()` function creates a mutable reactive object that subscribes to only the properties you read. It also returns a setter function you can use to mutate the original table:
+
+```luau
+local users, setUsers = reactive({
+ user = { name = "John", surname = "Doe" },
+})
+
+-- Output: John Doe
+effect(function()
+ print(`{users.user.name} {users.user.surname}`)
+end)
+
+-- Output: Jane Smith
+setUsers(function(state)
+ state.user.name = "Jane"
+ state.user.surname = "Smith"
+end)
+```
+
+You can also set properties on the reactive object directly:
+
+```luau
+local id = reactive({ name = "John", surname = "Doe" })
+
+effect(function()
+ print(`{id.name} {id.surname}`)
+end)
+
+id.name = "Jane" -- Jane Doe
+id.surname = "Smith" -- Jane Smith
+```
+
+> [!WARNING]
+> Be careful when mutating the reactive object! Because it's a proxy, functions like `table.insert` will not work. Table functions should be called on the original table, which is provided by the setter function.
+
+---
+
+### `flags`
+
+Charm exposes the following global flags to customize behavior:
+
+| Flag | Default | Description |
+| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
+| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. |
+| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
+
+The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower.
+
+---
+
+## Client-Server Sync
+
+### Installation
```sh
npm install @rbxts/charm-sync
@@ -441,265 +670,357 @@ pnpm add @rbxts/charm-sync
CharmSync = "littensy/charm-sync@VERSION"
```
----
+### Quick Start
-### `server(options)`
-
-Call `server` to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state.
+Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals:
```luau
-local syncer = CharmSync.server({
- -- A dictionary of the atoms to sync, matching the client's
- atoms = atomsToSync,
- -- The minimum interval between state updates
- interval = 0,
- -- Whether to send a full history of changes made to the atoms (slower)
- preserveHistory = false,
- -- Whether to apply fixes for remote event limitations. Disable this option
- -- when using a network library with custom ser/des, like ByteNet or Zap.
- autoSerialize = true,
-})
+-- nameStore
+local getName, setName = signal("John")
+local getSurname, setSurname = signal("Doe")
+local ageAtom = atom(20)
--- Sends state updates to clients when a synced atom changes.
--- Omitting sensitive information and data serialization can be done here.
-syncer:connect(function(player, ...)
- remotes.syncState:fire(player, ...)
-end)
-
--- Sends the initial state to a player upon request. This should fire when a
--- player joins the game.
-remotes.requestState:connect(function(player)
- syncer:hydrate(player)
-end)
+return {
+ getName = getName,
+ setName = setName,
+ getSurname = getSurname,
+ setSurname = setSurname,
+ ageAtom = ageAtom,
+}
```
-#### Parameters
+When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
-- `options`: An object to configure sync behavior.
-
- - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client.
-
- - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame.
-
- - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance.
-
- - **optional** `autoSerialize`: Whether to apply validation and workarounds to certain [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#table-indexing). Defaults to `true`, but you should set it to `false` if you serialize remote arguments (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)).
-
-> [!NOTE]
-> Charm sends table updates in the form of partial tables, so arrays will contain `nil` values, which has undefined behavior in remotes without serialization.
->
-> Charm's default `autoSerialize` behavior fixes this, but it can interfere with custom serialization. Disable this option if you use a network library that serializes remote event arguments.
-
-#### Returns
-
-`server` returns an object with the following methods:
-
-- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload.
-
-- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms.
-
-#### Caveats
-
-- **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries. [Read more about argument limitations in remotes.](https://create.roblox.com/docs/scripting/events/remote#argument-limitations)
-
-- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`.
-
-- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `autoSerialize` accordingly!
-
----
-
-### `client(options)`
-
-Call `client` to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom.
+Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to.
```luau
-local syncer = CharmSync.client({
- atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's
- ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update
-})
-
--- Applies state updates from the server to the client's atoms.
--- Data deserialization can be done here.
-remotes.syncState:connect(function(...)
- syncer:sync(...)
-end)
-
--- Requests the initial state from the server when the client joins the game.
--- Before this runs, the client uses the atoms' default values.
-remotes.requestState:fire()
-```
-
-#### Parameters
-
-- `options`: An object to configure sync behavior.
-
- - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server.
-
- - **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`.
-
-#### Returns
-
-`client` returns an object with the following methods:
-
-- `syncer:sync(...payloads)` applies a state update from the server.
-
-#### Caveats
-
-- **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above.
-
----
-
-### `isNone(value)`
-
-Call `isNone` to check if a value is `None`. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the `None` marker.
-
-This function can be used to check whether a value is about to be removed from an atom.
-
-```luau
-local syncer = CharmSync.server({ atoms = atomsToSync })
-
-syncer:connect(function(player, payload)
- if
- payload.type === "patch"
- and payload.data.todosAtom
- and CharmSync.isNone(payload.data.todosAtom.eggs)
- then
- -- 'eggs' will be removed from the client's todo list
- end
- remotes.syncState.fire(player, payload)
-end)
-```
-
-#### Parameters
-
-- `value`: Any value. If the value is `None`, `isNone` will return `true`.
-
-#### Returns
-
-`isNone` returns a boolean.
-
----
-
-## 🚀 Examples
-
-### Counter atom
-
-```luau
-local counterAtom = atom(0)
-
--- Create a selector that returns double the counter value
-local function doubleCounter()
- return counterAtom() * 2
+local function onPlayerAdded(player: Player)
+ -- Add signal getters, computed signals, atoms, or reactive objects
+ server.addSignalsToClient(player, {
+ name = nameStore.getName,
+ surname = nameStore.getSurname,
+ age = nameStore.ageAtom,
+ })
end
--- Runs after counterAtom is updated and prints double the new value
-subscribe(doubleCounter, function(value)
- print(value)
+for _, player in Players:GetPlayers() do
+ onPlayerAdded(player)
+end
+
+Players.PlayerAdded:Connect(onPlayerAdded)
+
+Players.PlayerRemoving:Connect(function(player)
+ server.removeClient(player)
end)
-counterAtom(1) --> 2
-counterAtom(function(count)
- return count + 1
-end) --> 4
+server.connect(function(player, updates)
+ -- Customize how you send state updates to clients
+ syncEvent:FireClient(player, updates)
+end)
```
-### React component
+> [!NOTE]
+> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning.
+
+To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts.
+
+After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
```luau
-local counter = require(script.Parent.counter)
-local counterAtom = counter.counterAtom
-local incrementCounter = counter.incrementCounter
+-- Add signal setters, atoms, or reactive objects
+client.addSignals({
+ name = nameStore.setName,
+ surname = nameStore.setSurname,
+ age = nameStore.ageAtom,
+})
+
+-- Update client signals when receiving updates from the server
+syncEvent.OnClientEvent:Connect(function(updates)
+ client.patch(updates)
+end)
+```
+
+---
+
+### `config`
+
+A configuration table that customizes the behavior of Charm Sync on the server.
+
+| 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 Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
+| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). |
+
+---
+
+### `server.addSignalsToClient(client, getters)`
+
+The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed.
+
+You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals.
+
+```luau
+Players.PlayerAdded:Connect(function(player)
+ server.addSignalsToClient(player, {
+ name = nameStore.getName,
+ surname = nameStore.getSurname,
+ age = nameStore.ageAtom,
+ })
+end)
+```
+
+You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player:
+
+```luau
+server.addSignalsToClient(player, {
+ [`data-{player.UserId}`] = computed(function()
+ return getPlayerData(player.UserId)
+ end),
+})
+```
+
+---
+
+### `server.removeSignalsFromClient(client, ...keys)`
+
+The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`.
+
+```luau
+server.addSignalsToClient(player, {
+ name = nameStore.getName,
+ surname = nameStore.getSurname,
+})
+
+server.removeSignalsFromClient(player, "surname")
+```
+
+---
+
+### `server.removeClient(client)`
+
+The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game.
+
+```luau
+Players.PlayerAdded:Connect(function(player)
+ 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({
+ name = nameStore.setName,
+ surname = nameStore.setSurname,
+ age = nameStore.ageAtom,
+})
+```
+
+---
+
+### `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({
+ name = nameStore.setName,
+ surname = nameStore.setSurname,
+})
+
+client.removeSignals("name")
+```
+
+---
+
+### `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({
+ name = nameStore.setName,
+ surname = nameStore.setSurname,
+})
+
+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 = {
+ name = signalToAtom(nameStore.getName, nameStore.setName),
+ surname = signalToAtom(nameStore.getSurname, nameStore.setSurname),
+}
+
+client.addSignals(sharedState)
+server.addSignalsToClient(client, sharedState)
+```
+
+---
+
+### Sync Caveats
+
+Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`.
+
+But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`.
+
+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.
+
+---
+
+## Migration
+
+Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that might help you migrate from an older version.
+
+
+Terminology changes
+
+- Signal: A state container with one function to get the state, and another to update it
+- Atom: A signal with the getter and setter combined into one function
+- Effect: Main way to react to state changes
+- `subscribe(getter, callback)`: Creates an effect that only subscribes to signals accessed in the getter
+
+
+
+**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:
+ - The second arguments of `atom()` changed from an `options` table to an equality function
+ - Removed the second argument of `computed()` (you can do your own equality checks now)
+ - Removed the 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 if you didn't use the old `__DEV__` flag. The flags have the following behavior:
+ - `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error
+ - `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
+
+4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent.
+ - This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`.
+ - Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback).
+ - This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`flags.trackInnerEffects`](#flags) to `false`.
+
+> [!NOTE]
+> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`.
+>
+> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI.
+
+5. Recursion is now disallowed in effects and computed signals by default. This change may introduce bugs in code relying on the old behavior.
+ - Recursive checks are opt-out. You can allow recursion for a specific effect by calling [`recursive()`](#recursive) at the top of the effect callback.
+
+6. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
+ - Added [`signal()`](#signalinitialvalue-equals) to make reads and writes more explicit
+ - Added [`listen()`](#listengetter-callback) to create a subscription that runs once immediately
+ - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once
+ - Added [`onCleanup()`](#oncleanupcallback-failsilently) to bind a cleanup function to the active effect
+ - Added [`trigger()`](#triggercallback) to trigger updates for table mutations
+ - The [`computed()`](#computedgetter) callback now gets called with the previous computed value
+ - Optimized `computed()` to use lazy evaluation instead of eager updates
+ - Optimized `mapped()` to only map values that changed in the original table
+
+---
+
+## Examples
+
+Check out this simple [example project →](https://github.com/littensy/charm-example)
+
+### React Counter
+
+```luau
+local getCounter, setCounter = signal(0)
local function Counter()
- local count = useAtom(counterAtom)
+ local count = useSignalState(getCounter)
return React.createElement("TextButton", {
- [React.Event.Activated] = incrementCounter,
+ [React.Event.Activated] = function()
+ setCounter(count + 1)
+ end,
Text = `Count: {count}`,
- Size = UDim2.new(0, 100, 0, 50),
+ Size = UDim2.fromOffset(100, 50),
})
end
```
-### Vide component
+### Vide Counter
```luau
-local counter = require(script.Parent.counter)
-local counterAtom = counter.counterAtom
-local incrementCounter = counter.incrementCounter
+local getCounter, setCounter = signal(0)
local function Counter()
- local count = useAtom(counterAtom)
+ local count = useSignalState(getCounter)
return create "TextButton" {
- Activated = incrementCounter,
+ Activated = function()
+ setCounter(function(count)
+ return count + 1
+ end)
+ end,
Text = function()
return `Count: {count()}`
end,
- Size = UDim2.new(0, 100, 0, 50),
+ Size = UDim2.fromOffset(100, 50),
}
end
```
-### Server-client sync
-
-Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events.
-
-Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client:
-
-```luau
--- atoms.luau
-local counter = require(script.Parent.counter)
-local todos = require(script.Parent.todos)
-
-return {
- counterAtom = counter.counterAtom,
- todosAtom = todos.todosAtom,
-}
-```
-
-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.
-
-> [!NOTE]
-> If `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a variadic `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter.
-
-```luau
--- sync.server.luau
-local atoms = require(script.Parent.atoms)
-
-local syncer = CharmSync.server({ atoms = atoms })
-
--- Sends state updates to clients when a synced atom changes.
--- Omitting sensitive information and data serialization can be done here.
-syncer:connect(function(player, payload)
- remotes.syncState.fire(player, payload)
-end)
-
--- Sends the initial state to a player upon request. This should fire when a
--- player joins the game.
-remotes.requestState:connect(function(player)
- syncer:hydrate(player)
-end)
-```
-
-Finally, on the client, create a client sync object and use it to apply incoming state changes.
-
-```luau
--- sync.client.luau
-local atoms = require(script.Parent.atoms)
-
-local syncer = CharmSync.client({ atoms = atoms })
-
--- Applies state updates from the server to the client's atoms.
-remotes.syncState:connect(function(payload)
- syncer:sync(payload)
-end)
-
--- Requests the initial state from the server when the client joins the game.
--- Before this runs, the client uses the atoms' default values.
-remotes.requestState:fire()
-```
-
---
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..9c24c6e
--- /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, elapsed in times do
+ table.insert(results, `{name}: \t{string.format("%.2f", elapsed / 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..27ca6bd
--- /dev/null
+++ b/benches/mapped.luau
@@ -0,0 +1,83 @@
+local Charm = require("../packages/charm/src")
+local bench = require("./bench")
+
+local computed = Charm.computed
+local mapped = Charm.mapped
+local setActiveSub = Charm.setActiveSub
+local signal = Charm.signal
+
+type Entry = { number }
+
+local count = 512
+local initialState: { Entry } = table.create(count)
+
+for i = 1, count do
+ initialState[i] = table.create(64, i)
+end
+
+local function mapper(value: Entry, key: number): (Entry, number)
+ return table.clone(value), key + 1
+end
+
+local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
+ return computed(function()
+ local state = source()
+ local nextState = {}
+
+ for index, value in state do
+ local currentSub = setActiveSub(nil)
+ local success, valueOut, keyOut = pcall(mapper, value, index)
+ setActiveSub(currentSub)
+
+ if success then
+ nextState[keyOut] = valueOut
+ end
+ end
+
+ return state
+ end)
+end
+
+local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
+ return function(state)
+ local nextState = table.clone(state)
+ for index, value in state do
+ if math.random() < percent then
+ nextState[index] = table.clone(value)
+ end
+ end
+ return nextState
+ end
+end
+
+for _, percent in { 0.1, 0.5, 1 } do
+ bench.run(`mapped {percent}`, function()
+ local src, setSrc = signal(initialState)
+ local out = mapped(src, mapper)
+
+ bench.profilebegin("init")
+ out()
+ bench.profileend()
+
+ setSrc(updatePercentage(percent))
+
+ bench.profilebegin("update")
+ out()
+ bench.profileend()
+ end)
+
+ bench.run(`control {percent}`, function()
+ local src, setSrc = signal(initialState)
+ local out = simpleMapped(src, mapper)
+
+ bench.profilebegin("init")
+ out()
+ bench.profileend()
+
+ setSrc(updatePercentage(percent))
+
+ bench.profilebegin("update")
+ out()
+ bench.profileend()
+ 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..2e499ca
--- /dev/null
+++ b/benches/reactive.luau
@@ -0,0 +1,87 @@
+local Charm = require("../packages/charm/src")
+local bench = require("./bench")
+
+local effect = Charm.effect
+local reactive = Charm.reactive
+
+do
+ local value = reactive({ a = 1 })
+ bench.run("track reactive property", function()
+ effect(function()
+ local _ = value.a
+ end)
+ end)
+end
+
+do
+ local value = reactive({ a = 1 })
+ bench.run("write reactive property", function()
+ value.a += 1
+ end)
+end
+
+for _, amount in { 10, 100, 1000 } do
+ print("---")
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ bench.run(`track for loop, {amount} elements`, function()
+ local sum = 0
+ effect(function()
+ for i = 1, #array do
+ sum += array[i]
+ end
+ end)
+ end)
+ end
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ bench.run(`track iteration, {amount} elements`, function()
+ local sum = 0
+ effect(function()
+ for _, x in array do
+ sum += x
+ end
+ end)
+ end)
+ end
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ effect(function()
+ local sum = 0
+ for _, x in array do
+ sum += x
+ end
+ end)
+
+ bench.run(`track index mutation (1st only), {amount} elements`, function()
+ array[1] += 1
+ end)
+ end
+
+ do
+ local rawArray = table.create(amount, 1)
+ local array = reactive(rawArray)
+
+ effect(function()
+ local sum = 0
+ for _, x in array do
+ sum += x
+ end
+ end)
+
+ bench.run(`track index mutation (all), {amount} elements`, function()
+ for i = 1, #array do
+ array[i] += 1
+ end
+ end)
+ end
+end
diff --git a/bin/build.sh b/bin/build.sh
index 547e4e7..f680537 100644
--- a/bin/build.sh
+++ b/bin/build.sh
@@ -1,5 +1,4 @@
+#!/usr/bin/env bash
+
mkdir build
-rojo build packages/charm -o build/charm.rbxm
-rojo build packages/charm-sync -o build/charm-sync.rbxm
-rojo build packages/react -o build/react-charm.rbxm
-rojo build packages/vide -o build/vide-charm.rbxm
+rojo build build.project.json -o build/charm.rbxm
diff --git a/bin/check.sh b/bin/check.sh
index 0e948f8..091c4a4 100644
--- a/bin/check.sh
+++ b/bin/check.sh
@@ -1,29 +1,19 @@
-curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
+curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
rojo sourcemap -o sourcemap.json
-check() {
- echo "Checking $1"
+luau-lsp analyze \
+ --defs=bin/roblox.d.luau \
+ --flag:LuauFixIndexerSubtypingOrdering=true \
+ --flag:LuauInstantiateInSubtyping=true \
+ --sourcemap=sourcemap.json \
+ --ignore="**/node_modules/**" \
+ packages test benches
- luau-lsp analyze \
- --defs=bin/roblox.d.luau \
- --defs=bin/testez.d.luau \
- --flag:LuauFixIndexerSubtypingOrdering=true \
- --flag:LuauInstantiateInSubtyping=true \
- --sourcemap=sourcemap.json \
- --ignore="**/_Index/**" \
- $1
+selene packages test benches
- selene $1
- stylua --check $1
-}
+stylua --check packages test benches
-eslint packages
-
-for i in packages/*/src; do
- check $i
-done
-
-check tests
+pnpm eslint packages
rm bin/roblox.d.luau
diff --git a/bin/install.sh b/bin/install.sh
deleted file mode 100644
index 53ccca2..0000000
--- a/bin/install.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-cd modules
-wally install
-cd ..
-rojo sourcemap --output sourcemap.json
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..8141b86
--- /dev/null
+++ b/build.project.json
@@ -0,0 +1,10 @@
+{
+ "name": "Packages",
+ "tree": {
+ "$className": "Folder",
+ "Charm": { "$path": "packages/charm/src" },
+ "CharmSync": { "$path": "packages/charm-sync/src" },
+ "ReactCharm": { "$path": "packages/react-charm/src" },
+ "VideCharm": { "$path": "packages/vide-charm/src" }
+ }
+}
diff --git a/default.project.json b/default.project.json
index a43197e..43a7d1a 100644
--- a/default.project.json
+++ b/default.project.json
@@ -1,29 +1,50 @@
{
"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": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
- "DevPackages": {
- "$path": "modules/DevPackages",
- "Charm": {
- "$path": "packages/charm"
+ "packages": {
+ "$className": "Folder",
+ "vide": {
+ "$path": "node_modules/@rbxts/vide"
},
- "CharmSync": {
- "$path": "packages/charm-sync"
+ "charm": {
+ "$className": "Folder",
+ "src": { "$path": "packages/charm/src" }
},
- "ReactCharm": {
- "$path": "packages/react"
+ "charm-sync": {
+ "$className": "Folder",
+ "src": { "$path": "packages/charm-sync/src" },
+ "Charm": { "$path": "packages/charm-sync/Charm.luau" }
},
- "VideCharm": {
- "$path": "packages/vide"
+ "react-charm": {
+ "$className": "Folder",
+ "src": { "$path": "packages/react-charm/src" },
+ "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" },
+ "Vide": { "$path": "packages/vide-charm/Vide.luau" },
+ "Charm": { "$path": "packages/vide-charm/Charm.luau" }
}
- }
- },
- "ServerScriptService": {
- "$className": "ServerScriptService",
- "Tests": {
- "$path": "tests"
+ },
+ "@rbxts-js": {
+ "$path": "node_modules/@rbxts-js"
+ },
+ "test": {
+ "$path": "test"
}
}
}
diff --git a/eslint.config.ts b/eslint.config.ts
new file mode 100644
index 0000000..21ece76
--- /dev/null
+++ b/eslint.config.ts
@@ -0,0 +1,20 @@
+import eslint from "@eslint/js";
+import { defineConfig, globalIgnores } from "eslint/config";
+import prettierRecommended from "eslint-plugin-prettier/recommended";
+import roblox from "eslint-plugin-roblox-ts";
+import tseslint from "typescript-eslint";
+
+export default defineConfig(
+ eslint.configs.recommended,
+ tseslint.configs.recommended,
+ roblox.configs.tsRecommendedCompat,
+ roblox.configs.recommended,
+ {
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ "roblox-ts/no-any": "off",
+ },
+ },
+ globalIgnores(["out/**"]),
+ prettierRecommended,
+);
diff --git a/modules/wally.lock b/modules/wally.lock
deleted file mode 100644
index 7294d77..0000000
--- a/modules/wally.lock
+++ /dev/null
@@ -1,103 +0,0 @@
-# This file is automatically @generated by Wally.
-# It is not intended for manual editing.
-registry = "test"
-
-[[package]]
-name = "centau/vide"
-version = "0.3.1"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/boolean"
-version = "1.2.7"
-dependencies = [["number", "jsdotlua/number@1.2.7"]]
-
-[[package]]
-name = "jsdotlua/collections"
-version = "1.2.7"
-dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]]
-
-[[package]]
-name = "jsdotlua/console"
-version = "1.2.7"
-dependencies = [["collections", "jsdotlua/collections@1.2.7"]]
-
-[[package]]
-name = "jsdotlua/es7-types"
-version = "1.2.7"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/instance-of"
-version = "1.2.7"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/luau-polyfill"
-version = "1.2.7"
-dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]]
-
-[[package]]
-name = "jsdotlua/math"
-version = "1.2.7"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/number"
-version = "1.2.7"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/promise"
-version = "3.5.2"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/react"
-version = "17.2.1"
-dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]]
-
-[[package]]
-name = "jsdotlua/react-reconciler"
-version = "17.2.1"
-dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]]
-
-[[package]]
-name = "jsdotlua/react-roblox"
-version = "17.2.1"
-dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]]
-
-[[package]]
-name = "jsdotlua/scheduler"
-version = "17.2.1"
-dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]]
-
-[[package]]
-name = "jsdotlua/shared"
-version = "17.2.1"
-dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]]
-
-[[package]]
-name = "jsdotlua/string"
-version = "1.2.7"
-dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]]
-
-[[package]]
-name = "jsdotlua/symbol-luau"
-version = "1.0.1"
-dependencies = []
-
-[[package]]
-name = "jsdotlua/timers"
-version = "1.2.7"
-dependencies = [["collections", "jsdotlua/collections@1.2.7"]]
-
-[[package]]
-name = "littensy/charm"
-version = "0.1.0"
-dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]]
-
-[[package]]
-name = "roblox/testez"
-version = "0.4.1"
-dependencies = []
diff --git a/modules/wally.toml b/modules/wally.toml
deleted file mode 100644
index 096ab3f..0000000
--- a/modules/wally.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[package]
-name = "littensy/charm"
-version = "0.1.0"
-registry = "https://github.com/UpliftGames/wally-index"
-realm = "shared"
-
-[dev-dependencies]
-React = "jsdotlua/react@17.2.0"
-ReactRoblox = "jsdotlua/react-roblox@17.2.0"
-TestEZ = "roblox/testez@0.4.1"
-Vide = "centau/vide@0.3.1"
diff --git a/package.json b/package.json
index 73e2375..b9b8739 100644
--- a/package.json
+++ b/package.json
@@ -1,24 +1,30 @@
{
"private": true,
"scripts": {
+ "test": "lune run test/spec.server.luau",
"build": "sh bin/build.sh",
"check": "sh bin/check.sh",
- "wally": "sh bin/install.sh",
"publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
},
+ "packageManager": "pnpm@10.22.0",
"devDependencies": {
+ "@eslint/js": "^9.36.0",
"@rbxts/compiler-types": "3.0.0-types.0",
- "@rbxts/types": "^1.0.811",
- "@typescript-eslint/eslint-plugin": "^7.18.0",
- "@typescript-eslint/parser": "^7.18.0",
- "eslint": "^8.57.1",
- "eslint-config-prettier": "^9.1.0",
- "eslint-plugin-prettier": "^5.2.1",
- "eslint-plugin-roblox-ts": "^0.0.36",
- "prettier": "^3.3.3",
+ "@rbxts/react": "17.3.5-alpha.1",
+ "@rbxts/react-roblox": "17.3.5-alpha.1",
+ "@rbxts/types": "^1.0.883",
+ "@rbxts/vide": "^0.5.7",
+ "@typescript-eslint/eslint-plugin": "^8.44.1",
+ "@typescript-eslint/parser": "^8.44.1",
+ "eslint": "^9.36.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "eslint-plugin-roblox-ts": "^1.2.1",
+ "jiti": "^2.6.0",
+ "prettier": "^3.6.2",
"roblox-ts": "^3.0.0",
- "typescript": "~5.5.4"
- },
- "packageManager": "pnpm@9.15.4"
+ "typescript": "^5.9.2",
+ "typescript-eslint": "^8.44.1"
+ }
}
diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau
new file mode 100644
index 0000000..9a55fe0
--- /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
index 5115ff0..9c44c7c 100644
--- a/packages/charm-sync/default.project.json
+++ b/packages/charm-sync/default.project.json
@@ -1,5 +1,5 @@
{
- "name": "CharmSync",
+ "name": "charm-sync",
"tree": {
"$path": "src"
}
diff --git a/packages/charm-sync/package.json b/packages/charm-sync/package.json
index 5256c2d..2251c77 100644
--- a/packages/charm-sync/package.json
+++ b/packages/charm-sync/package.json
@@ -1,16 +1,27 @@
{
"name": "@rbxts/charm-sync",
- "version": "0.3.0",
- "description": "Sync server state with clients using Charm",
- "repository": "littensy/charm",
+ "version": "0.4.0-rc.2",
+ "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",
+ "!**/__tests__"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/littensy/charm.git"
+ },
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
- "peerDependencies": {
+ "dependencies": {
"@rbxts/charm": "workspace:*"
}
}
diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau
new file mode 100644
index 0000000..10164a2
--- /dev/null
+++ b/packages/charm-sync/src/__tests__/client.spec.luau
@@ -0,0 +1,139 @@
+local Charm = require("../../Charm")
+local client = require("../client")
+local suite = require("../../../../test/suite")
+
+local test = suite.test
+local afterEach = suite.afterEach
+
+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 = Charm.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/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau
new file mode 100644
index 0000000..d8da3aa
--- /dev/null
+++ b/packages/charm-sync/src/__tests__/e2e.spec.luau
@@ -0,0 +1,99 @@
+local Charm = require("../../Charm")
+local client = require("../client")
+local config = require("../config")
+local server = require("../server")
+local suite = require("../../../../test/suite")
+
+local afterEach = suite.afterEach
+local test = suite.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 = Charm.reactive({ count = 0 })
+ local clientA = Charm.atom({ count = -1 })
+ local clientB = Charm.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 = Charm.reactive({ count = 0 })
+ local clientA = Charm.atom({ count = -1 })
+ local clientB = Charm.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/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau
new file mode 100644
index 0000000..65c0e45
--- /dev/null
+++ b/packages/charm-sync/src/__tests__/patch.spec.luau
@@ -0,0 +1,130 @@
+local config = require("../config")
+local patch = require("../patch")
+local suite = require("../../../../test/suite")
+
+local test = suite.test
+local afterEach = suite.afterEach
+
+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
+
+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)
+
+return {}
diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau
new file mode 100644
index 0000000..4a30449
--- /dev/null
+++ b/packages/charm-sync/src/__tests__/server.spec.luau
@@ -0,0 +1,349 @@
+local Charm = require("../../Charm")
+local config = require("../config")
+local patch = require("../patch")
+local server = require("../server")
+local suite = require("../../../../test/suite")
+
+local afterEach = suite.afterEach
+local beforeEach = suite.beforeEach
+local describe = suite.describe
+local test = suite.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, payloads
+
+ 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, payloads
+
+ 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: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
+ local target, payloads
+
+ 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 == a.x, "Expected init payload to contain value 'a.x'")
+ assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'")
+ assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
+
+ a.x = 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 == 1, "Expected patch payload to contain value 'a.x'")
+ assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'")
+ assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
+end)
+
+test("should init new clients", function()
+ local clientA = {}
+ local clientB = {}
+ local a = Charm.atom(0)
+ local payloadsA, payloadsB
+
+ 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, payloads
+
+ 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, payloadsB
+
+ 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
+
+ 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)
+
+describe("historical", function()
+ beforeEach(function()
+ config.preserveHistory = true
+ end)
+
+ 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, payloads
+
+ 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 = Charm.reactive({ value = 0 })
+ local b = Charm.reactive({ value = 0 })
+ local c = Charm.reactive({ value = 0 })
+ local target, payloads
+
+ server.addSignalsToClient(client, { a = a, b = b, c = c })
+ server.connect(function(...)
+ target, payloads = ...
+ end)
+
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "init", "Expected first payload to be init")
+ assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
+ assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
+ assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
+
+ a.value = 1
+ a.value = 2
+ b.value = 2
+ server.flush()
+ assert(target == client, "Expected target to be the client")
+ assert(payloads[1].type == "patch", "Expected first payload to be patch")
+ assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
+ assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
+ assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
+ end)
+
+ test("should init new clients", function()
+ local clientA = {}
+ local clientB = {}
+ local a = Charm.atom(0)
+ local payloadsA, payloadsB
+
+ 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
+
+ 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)
+end)
+
+return {}
diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau
new file mode 100644
index 0000000..8d07f04
--- /dev/null
+++ b/packages/charm-sync/src/__tests__/spec.luau
@@ -0,0 +1,23 @@
+local suite = require("../../../../test/suite")
+
+suite.describe("client", function()
+ require("./client.spec")
+end)
+
+suite.describe("e2e", function()
+ require("./e2e.spec")
+end)
+
+suite.describe("utils", function()
+ require("./utils.spec")
+end)
+
+suite.describe("patch", function()
+ require("./patch.spec")
+end)
+
+suite.describe("server", function()
+ require("./server.spec")
+end)
+
+return {}
diff --git a/packages/charm-sync/src/__tests__/utils.spec.luau b/packages/charm-sync/src/__tests__/utils.spec.luau
new file mode 100644
index 0000000..47d2e39
--- /dev/null
+++ b/packages/charm-sync/src/__tests__/utils.spec.luau
@@ -0,0 +1,23 @@
+local Charm = require("../../Charm")
+local suite = require("../../../../test/suite")
+local utils = require("../utils")
+
+local test = suite.test
+
+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(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/src/client.luau b/packages/charm-sync/src/client.luau
index 8f3cfb1..43103a7 100644
--- a/packages/charm-sync/src/client.luau
+++ b/packages/charm-sync/src/client.luau
@@ -1,83 +1,105 @@
-local Charm = require(script.Parent.Parent.Charm)
-local patch = require(script.Parent.patch)
-local types = require(script.Parent.types)
-type AtomMap = types.AtomMap
+local Charm = require("../Charm")
+local patch = require("./patch")
+local types = require("./types")
+
type SyncPayload = types.SyncPayload
-type ClientOptions = {
- --[=[
- The atoms to synchronize with the server.
- ]=]
- atoms: AtomMap,
- --[=[
- Whether to ignore patches sent before the client has been hydrated.
- Default is `true`.
- ]=]
- ignoreUnhydrated: boolean?,
-}
+type SignalSetter = (update: ((T) -> T) | T) -> ()
-type ClientSyncer = {
- --[=[
- Applies a patch or initializes the state of the atoms with the given
- payload from the server.
-
- @param ...payloads The patches or hydration payloads to apply.
- ]=]
- sync: (self: ClientSyncer, ...SyncPayload) -> (),
-}
+type ReactiveSetter = (update: ((T) -> ()) | T) -> ()
---[=[
- Creates a `ClientSyncer` object that receives patches from the server and
- applies them to the local state.
-
- @param options The atoms to synchronize with the server.
- @return A `ClientSyncer` object.
-]=]
-local function client(options: ClientOptions): ClientSyncer
- local atoms = options.atoms
- local ignoreUnhydrated = options.ignoreUnhydrated ~= false
+type Reactive = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter }))
- local self = {} :: ClientSyncer
- local hydrated = false
+local trackedSignals: { [string]: SignalSetter } = {}
+local trackedReactives: { [string]: ReactiveSetter } = {}
- local function hydrate(state: { [string | number]: any })
- hydrated = true
+--[[
+ 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.
- for key, value in next, state do
- local atom = atoms[key]
- atom(value)
+ 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]: SignalSetter | { [any]: any } })
+ for key, signal in signals do
+ if type(signal) == "function" then
+ trackedSignals[key] = signal
+ else
+ trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
end
end
+end
- local function apply(data: { [string | number]: any })
- local target = {}
+--[[
+ 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
- for key, atom in next, atoms do
- target[key] = atom()
- end
+--[[
+ Unsubscribes from all server state updates.
+]]
+local function removeAllSignals()
+ table.clear(trackedSignals)
+ table.clear(trackedReactives)
+end
- target = patch.apply(target, data)
-
- for key, atom in next, atoms do
- atom(target[key])
+local function applyInitPayload(data: { [string]: any })
+ for key, value in data do
+ local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key]
+ if trackedSignal then
+ trackedSignal(value)
end
end
+end
- function self:sync(...)
- for index = 1, select("#", ...) do
- local payload: SyncPayload = select(index, ...)
+local function applyPatchPayload(data: { [string]: any })
+ for key, diffs in data do
+ local trackedSignal = trackedSignals[key]
+ local trackedReactive = trackedReactives[key]
- Charm.batch(function()
- if payload.type == "init" then
- hydrate(payload.data)
- elseif not ignoreUnhydrated or hydrated then
- apply(payload.data)
- end
+ if trackedSignal then
+ trackedSignal(function(value)
+ return patch.apply(value, diffs)
+ end)
+ elseif trackedReactive then
+ trackedReactive(function(value)
+ patch.applyMutable(value, diffs)
end)
end
end
-
- return self
end
-return client
+--[[
+ Merges the server's state patches into the client's state, either
+ initializing it with the full state or merging a partial patch.
+
+ @param payloads The state updates received from the server.
+]]
+local function patch(payloads: { SyncPayload })
+ 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 = patch,
+}
diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau
new file mode 100644
index 0000000..a101f05
--- /dev/null
+++ b/packages/charm-sync/src/config.luau
@@ -0,0 +1,28 @@
+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`.
+ ]]
+ preserveHistory = false,
+ --[[
+ When `true`, Charm will apply validation and serialize unsafe arrays
+ to address remote event argument limitations. Defaults to `true`.
+ This option should be disabled if your network library uses a custom
+ serialization method (i.e. Zap, ByteNet) to prevent interference.
+ ]]
+ fixArrays = true,
+ --[[
+ When `true`, synced state containing unsafe sparse arrays or mixed
+ tables will throw an error. Only checked in strict mode and if
+ `fixArrays` is enabled. Defaults to `true`.
+ ]]
+ validatePatches = true,
+}
diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau
deleted file mode 100644
index 9c8d012..0000000
--- a/packages/charm-sync/src/flatten.luau
+++ /dev/null
@@ -1,28 +0,0 @@
-local types = require(script.Parent.types)
-type AtomMap = types.AtomMap
-
-type NestedAtomMap = {
- [string]: NestedAtomMap | () -> (),
-}
-
-local function flatten(atoms: NestedAtomMap): AtomMap
- local result: AtomMap = {}
-
- local function visit(node: NestedAtomMap, path: string)
- for key, value in node do
- local location = if path == "" then key else path .. "/" .. key
-
- if type(value) == "table" then
- visit(value, location)
- else
- result[location] = value
- end
- end
- end
-
- visit(atoms, "")
-
- return result
-end
-
-return flatten
diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts
index 52ff658..6519951 100644
--- a/packages/charm-sync/src/index.d.ts
+++ b/packages/charm-sync/src/index.d.ts
@@ -1,214 +1,289 @@
-import { Atom, Selector } from "@rbxts/charm";
-
-export = CharmSync;
-export as namespace CharmSync;
-
-type Cleanup = () => void;
+import { Atom, Setter } from "@rbxts/charm";
type Key = string | number | symbol;
-declare namespace CharmSync {
- type AtomMap = Record>;
+/**
+ * Represents the removal of a value from the state.
+ */
+export interface None {
+ readonly __none: "__none";
+}
- type SelectorMap = Record>;
+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: {
/**
- * @deprecated Use `SelectorMap` instead.
- */
- type MoleculeMap = SelectorMap;
-
- /**
- * Infers the type of the return values produced by a map of functions.
- */
- type StateOfMap = {
- readonly [P in keyof T]: T[P] extends Selector ? State : never;
- };
-
- /**
- * Represents the removal of a value from the state.
- */
- interface None {
- readonly __none: "__none";
- }
-
- type MaybeNone = undefined extends T ? None : never;
-
- /**
- * Creates a `ClientSyncer` object that receives patches from the server and
- * applies them to the local state.
- *
- * @client
- * @param options The atoms to synchronize with the server.
- * @returns A `ClientSyncer` object.
- */
- function client(options: ClientOptions): ClientSyncer;
-
- /**
- * Creates a `ServerSyncer` object that sends patches to the client and
- * hydrates the client's state.
+ * 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
- * @param options The atoms to synchronize with the client.
- * @returns A `ServerSyncer` object.
*/
- function server(
- options: ServerOptions,
- ): ServerSyncer;
-
+ interval: number;
/**
- * Checks whether a value is `None`. If `true`, the value is scheduled to be
- * removed from the state when the patch is applied.
+ * 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`.
*
- * @param value The value to check.
- * @returns `true` if the value is `None`, otherwise `false`.
+ * @server
*/
- function isNone(value: unknown): value is None;
-
- interface NestedAtomMap {
- readonly [key: string]: NestedAtomMap | Atom;
- }
-
- type AppendPath = undefined extends Path
- ? Name
- : Name extends string
- ? Path extends string
- ? `${Path}/${Name}`
- : never
- : never;
-
- type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
-
- type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
-
- type FlattenNestedAtoms = IntersectValues<{
- readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom
- ? { readonly [K in AppendPath]: Atoms[Name] }
- : Atoms[Name] extends NestedAtomMap
- ? FlattenNestedAtoms>
- : never;
- }>;
-
+ preserveHistory: boolean;
/**
- * Flattens a nested atom map into a single object with slash-separated
- * keys. Useful for recursively collecting atoms returned by modules.
+ * When `true`, Charm will apply validation and serialize unsafe arrays
+ * to address remote event argument limitations. Defaults to `true`.
+ * This option should be disabled if your network library uses a custom
+ * serialization method (i.e. Zap, ByteNet) to prevent interference.
*
- * @param atoms The nested atom map to flatten.
- * @return A flattened atom map.
+ * @server
*/
- function flatten(atoms: Atoms): FlattenNestedAtoms;
-
- type DataTypes = {
- [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
- };
-
+ fixArrays: boolean;
/**
- * A type that should not be made partial in patches.
- */
- 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.
+ * When `true`, synced state containing unsafe sparse arrays or mixed
+ * tables will throw an error. Only checked in strict mode and if
+ * `fixArrays` is enabled. Defaults to `true`.
*
- * If a value was removed, it is replaced with `None`. This can be checked
- * using the `sync.isNone` function.
+ * @server
*/
- type SyncPatch = MaybeNone | State extends
- | ReadonlyMap
- | Map
- ? ReadonlyMap | None>
- : State extends Set | ReadonlySet
- ? ReadonlyMap
- : State extends readonly (infer T)[]
- ? Serialize extends true
- ? ReadonlyMap | None>
- : readonly (SyncPatch | None | undefined)[]
- : State extends DataType
- ? State
- : State extends object
- ? { readonly [P in keyof State]?: SyncPatch }
- : State;
+ 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;
/**
- * A payload that can be sent from the server to the client to synchronize
- * state between the two.
+ * 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.
*/
- type SyncPayload =
- | { readonly type: "init"; readonly data: StateOfMap }
- | { readonly type: "patch"; readonly data: SyncPatch, Serialize> };
+ export function removeSignals(
+ ...keys: (keyof Signals)[]
+ ): void;
- interface ClientOptions {
- /**
- * The atoms to synchronize with the server.
- */
- atoms: Atoms;
- /**
- * Whether to ignore patches sent before the client has been hydrated.
- * Defaults to `true`.
- */
- ignoreUnhydrated?: boolean;
- }
+ /**
+ * Unsubscribes from all server state updates.
+ */
+ export function removeAllSignals(): void;
- interface ServerOptions {
- /**
- * The atoms to synchronize with the client.
- */
- atoms: Selectors;
- /**
- * 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?: 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`.
- *
- * If `true`, the broadcaster will send a list of payloads to the client
- * instead of a single payload. The client will apply each payload in order
- * to reconstruct the state's changes over time.
- */
- preserveHistory?: boolean;
- /**
- * When `true`, Charm will apply validation and serialize unsafe arrays
- * to address remote event argument limitations. Defaults to `true`.
- *
- * This option should be disabled if your network library uses a custom
- * serialization method (i.e. Zap, ByteNet) to prevent interference.
- */
- autoSerialize?: Serialize;
- }
-
- interface ClientSyncer {
- /**
- * Applies a patch or initializes the state of the atoms with the given
- * payload from the server.
- *
- * @param payloads The patches or hydration payloads to apply.
- */
- sync(...payloads: SyncPayload[]): void;
- }
-
- interface ServerSyncer {
- /**
- * Sets up a subscription to each atom that schedules a patch to be sent to
- * the client whenever the state changes. When a change occurs, the `callback`
- * is called with the player and the payloads to send.
- *
- * Note that a `payload` object should not be mutated. If you need to modify
- * a payload, apply the changes to a copy of the object.
- *
- * @param callback The function to call when the state changes.
- * @returns A cleanup function that unsubscribes all listeners.
- */
- connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup;
- /**
- * Hydrates the client's state with the server's state. This should be
- * called when a player joins the game and requires the server's state.
- *
- * @param player The player to hydrate.
- */
- hydrate(player: Player): 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 apply(
+ 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
index 5fc9171..53a6353 100644
--- a/packages/charm-sync/src/init.luau
+++ b/packages/charm-sync/src/init.luau
@@ -1,18 +1,19 @@
-local client = require(script.client)
-local flatten = require(script.flatten)
-local patch = require(script.patch)
-local server = require(script.server)
-local types = require(script.types)
+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
---[=[
- Synchronizes state between the client and server. The server sends patches
- to the client, which applies them to its local state.
-]=]
return {
+ config = config,
client = client,
server = server,
- flatten = flatten,
+ patch = patch,
isNone = patch.isNone,
+ flatten = utils.flatten,
+ signalToAtom = utils.signalToAtom,
}
diff --git a/packages/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau
deleted file mode 100644
index 31df067..0000000
--- a/packages/charm-sync/src/interval.luau
+++ /dev/null
@@ -1,30 +0,0 @@
-local RunService = game:GetService("RunService")
-
---[=[
- Schedules a function to be called at a fixed interval of `seconds`.
-
- @param callback The function to call.
- @param seconds The interval in seconds.
- @return A function that cancels the interval.
-]=]
-local function interval(callback: () -> (), seconds: number): () -> ()
- if seconds < 0 then
- return function() end
- end
-
- local clock = 0
-
- local connection = RunService.Heartbeat:Connect(function(delta)
- clock += delta
- if clock >= seconds then
- clock = 0
- callback()
- end
- end)
-
- return function()
- connection:Disconnect()
- end
-end
-
-return interval
diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau
index 3d021b5..15bfd2c 100644
--- a/packages/charm-sync/src/patch.luau
+++ b/packages/charm-sync/src/patch.luau
@@ -1,27 +1,44 @@
-local validate = require(script.Parent.validate)
+local Charm = require("../Charm")
+local config = require("./config")
+local types = require("./types")
+local validate = require("./validate")
-local __DEV__ = _G.__DEV__
+local flags = Charm.flags
---[=[
- A special symbol that denotes the absence of a value. Used to represent
- deleted values in patches.
-]=]
-local None = { __none = "__none" }
+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 `apply` function will convert these dictionaries back into arrays as
+ 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 }): { [string | number]: any }
local maxn = table.maxn(object)
@@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
local dictionary = {}
- for index, value in next, object do
+ for index, value in object do
dictionary[tostring(index)] = value
end
return dictionary
end
---[=[
- Compares two states and returns a patch that can be applied to the previous
- state to produce the next state.
+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
- @param prevState The previous state.
- @param nextState The next state.
- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
- @returns A patch that can be applied to the previous state.
-]=]
-local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
- serialize = serialize ~= false
+ local diffs: { [any]: any } = table.clone(newState)
- local patches = table.clone(nextState)
+ for key, oldValue in oldState do
+ diffs[key] = diff(oldValue, newState[key])
+ end
- for key, previous in next, prevState do
- local current = nextState[key]
+ if next(diffs) == nil then
+ return nil
+ end
- if previous == current then
- patches[key] = nil
- elseif current == nil then
- patches[key] = None
- elseif type(previous) == "table" and type(current) == "table" then
- patches[key] = diff(previous, current, serialize)
+ 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
- -- Coerce sparse array patches into dictionaries
- if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
- patches = stringifySparseArray(patches)
- end
-
- if serialize and __DEV__ then
- for key, value in next, patches do
- validate(value, key)
- end
- end
-
- return patches
+ return diffs
end
---[=[
- Applies a patch to a state and returns the next state.
-
- @param state The current state.
- @param patches The patches to apply.
- @returns The next state.
-]=]
-local function apply(state: any, patches: any): any
- if type(patches) == "table" and patches.__none == "__none" then
+local function apply(oldState: any, diffs: any): any
+ if isNone(diffs) then
return nil
- elseif type(state) ~= "table" or type(patches) ~= "table" then
- return patches
+ elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
+ return diffs
end
- local nextState = table.clone(state)
- local stateIsArray = state[1] ~= nil
+ local newState = table.clone(oldState)
+ local stateIsArray = oldState[1] ~= nil
- for key, patch in next, patches do
+ for key, value in diffs do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
-
- nextState[key] = apply(nextState[key], patch)
+ newState[key] = apply(newState[key], value)
end
- return nextState
+ return newState
+end
+
+local function applyMutable(targetState: any, sourceState: any): any
+ if type(targetState) == "table" and type(sourceState) == "table" then
+ local targetIsArray = targetState[1] ~= nil
+
+ for key, value in sourceState do
+ if targetIsArray and type(key) == "string" then
+ key = tonumber(key) or key
+ end
+ targetState[key] = applyMutable(targetState[key], value)
+ end
+
+ return targetState
+ elseif isNone(sourceState) then
+ return nil
+ else
+ return sourceState
+ end
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
index f2c336e..a43e3b9 100644
--- a/packages/charm-sync/src/server.luau
+++ b/packages/charm-sync/src/server.luau
@@ -1,267 +1,413 @@
-local Players = game:GetService("Players")
+local Charm = require("../Charm")
+local config = require("./config")
+local patch = require("./patch")
+local types = require("./types")
+local utils = require("./utils")
-local Charm = require(script.Parent.Parent.Charm)
-local interval = require(script.Parent.interval)
-local patch = require(script.Parent.patch)
-local types = require(script.Parent.types)
-type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
-type ServerOptions = {
- --[=[
- The atoms to synchronize with the client.
- ]=]
- atoms: AtomMap,
- --[=[
- 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: 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`.
- ]=]
- preserveHistory: boolean?,
- --[=[
- When `true`, Charm will apply validation and serialize unsafe arrays
- to address remote event argument limitations. Defaults to `true`.
+type SyncStatus = number
- This option should be disabled if your network library uses a custom
- serialization method (i.e. Zap, ByteNet) to prevent interference.
- ]=]
- autoSerialize: boolean?,
+type ClientKeys = { [string]: SyncStatus }
+
+type Reactive = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
+
+type TrackedState = {
+ target: any,
+ currentValue: T,
+ lastSyncedValue: T,
+ dispose: () -> (),
+ isReactive: boolean?,
}
-type ServerSyncer = {
- --[=[
- Sets up a subscription to each atom that schedules a patch to be sent to
- the client whenever the state changes. When a change occurs, the `callback`
- is called with the player and the payload to send.
-
- Note that the `payload` object should not be mutated. If you need to
- modify the payload, apply the changes to a copy of the object.
-
- @param callback The function to call when the state changes.
- @return A cleanup function that unsubscribes all listeners.
- ]=]
- connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
- --[=[
- Hydrates the client's state with the server's state. This should be
- called when a player joins the game and requires the server's state.
-
- @param player The player to hydrate.
- ]=]
- hydrate: (self: ServerSyncer, player: Player) -> (),
- --[=[
- @deprecated For internal use only.
- ]=]
- _sendPatch: (self: ServerSyncer, player: Player) -> (),
+type StateUpdate = {
+ key: string,
+ newValue: any,
+ oldValue: any,
}
---[=[
- Creates a `ServerSyncer` object that sends patches to the client and
- hydrates the client's state.
-
- @param options The atoms to synchronize with the client.
- @return A `ServerSyncer` object.
-]=]
-local function server(options: ServerOptions): ServerSyncer
- local atoms = options.atoms
- local autoSerialize = options.autoSerialize ~= false
- local preserveHistory = options.preserveHistory
- local syncRate = options.interval or 0
+local PENDING_INITIAL_STATE: SyncStatus = 0
+local LISTENING_FOR_CHANGES: SyncStatus = 1
- local self = {} :: ServerSyncer
- local sync: (player: Player, payload: SyncPayload) -> ()
+local trackedStates: { [string]: TrackedState } = {}
+local globalUpdates: { StateUpdate } = {}
+local clients: { [any]: ClientKeys } = {}
- local function createSnapshot()
- local snapshot = {}
+local connection: RBXScriptConnection?
+local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
- for key, atom in next, atoms do
- snapshot[key] = atom()
+local function trackSignal(key: string, getter: () -> T)
+ if trackedStates[key] then
+ -- Ignore duplicates, but warn against name collisions
+ if trackedStates[key].target ~= getter then
+ warn("[Charm] Attempted to sync different states with the same key:", key)
end
-
- return snapshot
+ return
end
- -- Send the initial state to a player when they join the server.
- function self:hydrate(player)
- assert(sync, "connect() must be called before hydrate()")
+ local value = getter()
+ local trackedState = {
+ target = getter,
+ currentValue = value,
+ lastSyncedValue = value,
+ }
- sync(player, {
- type = "init",
- data = createSnapshot(),
- })
+ trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
+ -- When preserving history, we need to keep track of previous values
+ -- so we can create granular patches for each change.
+ if config.preserveHistory then
+ table.insert(globalUpdates, {
+ key = key,
+ newValue = newValue,
+ oldValue = oldValue,
+ })
+ end
+ trackedState.currentValue = newValue
+ end)
+
+ trackedStates[key] = trackedState
+end
+
+local function trackReactive(key: string, reactive: Reactive)
+ if trackedStates[key] then
+ if trackedStates[key].target ~= reactive then
+ warn("[Charm] Attempted to sync different states with the same key:", key)
+ end
+ return
end
- -- If the history of state changes should be preserved, keep a list of
- -- previous snapshots and diff-check each one from the last.
- if preserveHistory then
- local snapshots = { createSnapshot() }
- local changed = false
+ local meta = getmetatable(reactive)
+ local snapshot = utils.deepCopy(meta.initialValue)
+ local firstRun = true
- function self:connect(callback)
- local subscriptions = {}
+ local trackedState = {
+ target = reactive,
+ currentValue = meta.initialValue,
+ lastSyncedValue = snapshot,
+ isReactive = true,
+ }
- sync = callback
-
- local function pushSnapshot(key: string | number, current: any, previous: any)
- local lastSnapshot = snapshots[#snapshots]
- local previousSnapshot = snapshots[#snapshots - 1]
-
- -- Optimize snapshots by updating the most recent snapshot if the
- -- previous and current values are the same, since this allows us
- -- to group multiple changes into a single snapshot.
- if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
- lastSnapshot[key] = current
- else
- local nextSnapshot = table.clone(lastSnapshot)
- nextSnapshot[key] = current
- table.insert(snapshots, nextSnapshot)
- end
- end
-
- -- Populate the snapshot with the initial state of each atom.
- -- Subscribe to each atom and update the state when it changes.
- for key, atom in next, atoms do
- subscriptions[key] = Charm.subscribe(atom, function(current, previous)
- pushSnapshot(key, current, previous)
- changed = true
- end)
- end
-
- local disconnect = interval(function()
- if not changed then
- return
- end
-
- local payloads: { SyncPayload } = {}
- local lastSnapshot
-
- for index, snapshot in next, snapshots do
- lastSnapshot = snapshot
-
- if index == 1 then
- continue
- end
-
- table.insert(payloads, {
- type = "patch",
- data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
- })
- end
-
- snapshots = { lastSnapshot }
- changed = false
-
- for _, player in next, Players:GetPlayers() do
- callback(player, unpack(payloads))
- end
- end, syncRate)
-
- return function()
- disconnect()
-
- for _, unsubscribe in next, subscriptions do
- unsubscribe()
- end
- end
+ trackedState.dispose = Charm.effect(function()
+ meta.track()
+ if not config.preserveHistory or firstRun then
+ firstRun = false
+ else
+ local oldSnapshot = snapshot
+ snapshot = utils.deepCopy(meta.initialValue)
+ table.insert(globalUpdates, {
+ key = key,
+ newValue = snapshot,
+ oldValue = oldSnapshot,
+ })
end
+ end)
- function self:_sendPatch(player: Player)
- assert(sync, "connect() must be called before _sendPatch()")
+ trackedStates[key] = trackedState
+end
- if not changed then
- return
- end
+local function stopTracking(key: string)
+ local trackedState = trackedStates[key]
+ if trackedState then
+ trackedState.dispose()
+ trackedStates[key] = nil
+ end
+end
- local payloads: { SyncPayload } = {}
+local function purgeUntracked()
+ local active: { [string]: boolean } = {}
- for index, snapshot in next, snapshots do
- if index == 1 then
- continue
- end
-
- table.insert(payloads, {
- type = "patch",
- data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
- })
- end
-
- snapshots = { snapshots[#snapshots] }
- changed = false
-
- sync(player, unpack(payloads))
+ for _, clientKeys in clients do
+ for key in clientKeys do
+ active[key] = true
end
-
- return self
end
- local previousSnapshot = createSnapshot()
- local currentSnapshot = table.clone(previousSnapshot)
- local changed = false
+ for key in trackedStates do
+ if not active[key] then
+ stopTracking(key)
+ end
+ end
+end
- function self:connect(callback)
- local subscriptions = {}
+local function diffGlobalUpdateBuffer(): { any }
+ local patchCount = #globalUpdates
+ local patches = table.create(patchCount)
- sync = callback
+ for index, update in globalUpdates do
+ patches[index] = patch.diff(update.oldValue, update.newValue)
+ end
- -- Subscribe to each atom and update the state when one changes.
- for key, atom in next, atoms do
- subscriptions[key] = Charm.subscribe(atom, function(state)
- currentSnapshot[key] = state
- changed = true
- 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 not patchData then
+ continue
end
- local disconnect = interval(function()
- if not changed then
- return
- end
+ local key = update.key
+ local status = clientKeys[key]
- local payload: SyncPayload = {
+ if status == LISTENING_FOR_CHANGES then
+ table.insert(payloads, {
type = "patch",
- data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
- }
+ data = { [key] = patchData },
+ })
+ elseif status == PENDING_INITIAL_STATE then
+ table.insert(payloads, {
+ type = "init",
+ data = { [key] = update.newValue },
+ })
+ clientKeys[key] = LISTENING_FOR_CHANGES
+ end
+ end
- previousSnapshot = table.clone(currentSnapshot)
- changed = false
+ -- If a pending signal was never updated, we still need to send its initial
+ -- state. This can happen if a signal was registered after its last change.
+ local initData: { [string]: any } = {}
- for _, player in next, Players:GetPlayers() do
- callback(player, payload)
- end
- end, syncRate)
+ for key, status in clientKeys do
+ local trackedState = trackedStates[key]
- return function()
- disconnect()
+ if status == PENDING_INITIAL_STATE and trackedState then
+ initData[key] = trackedState.currentValue
+ clientKeys[key] = LISTENING_FOR_CHANGES
+ end
+ end
- for _, unsubscribe in next, subscriptions do
- unsubscribe()
+ if next(initData) ~= nil then
+ table.insert(payloads, 1, { type = "init" :: "init", data = initData })
+ end
+
+ return payloads
+end
+
+local function diffGlobalState(): 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: 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 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
+ table.insert(payloads, { type = "init", data = initData })
+ end
+
+ if next(patchData) ~= nil then
+ table.insert(payloads, { type = "patch", data = patchData })
+ end
+
+ return payloads
+end
+
+--[[
+ Subscribes a client to the given signals. When an update occurs, the client
+ will receive a partial state patch to merge into their local state. May be
+ called multiple times to subscribe to additional keys.
+
+ Atoms, signals converted to atoms, and signal getter functions are allowed.
+ Note that `client.addSignals` still requires some way to set the state.
+
+ @param client The client receiving state updates.
+ @param signals A map of getter functions to sync with the client.
+]]
+local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> 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)
+ else
+ trackReactive(key, signal :: any)
+ 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
+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)
+ 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 = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
+
+ for client in clients do
+ local payloads = if granular
+ then selectFromGlobalPatchList(client, globalPatch)
+ 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
- function self:_sendPatch(player)
- assert(sync, "connect() must be called before _sendPatch()")
+ purgeUntracked()
+ table.clear(globalUpdates)
+end
- if not changed then
+--[[
+ Uses the callback to send state updates to clients that are subscribed
+ to state changes. Starts automatically sending updates at the interval
+ specified in `config.interval`.
+
+ @param onSync Called when sending patches to a client.
+]]
+local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
+ if connection then
+ connection:Disconnect()
+ end
+
+ syncCallback = onSync
+
+ if not game then
+ return
+ end
+
+ local timer = 0
+
+ connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
+ if config.interval < 0 then
return
end
- local payload: SyncPayload = {
- type = "patch",
- data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
- }
+ timer += deltaTime
- previousSnapshot = table.clone(currentSnapshot)
- changed = false
-
- sync(player, payload)
- end
-
- return self
+ if timer >= config.interval then
+ timer = 0
+ debug.profilebegin("CharmSync")
+ flush()
+ debug.profileend()
+ end
+ end)
end
-return server
+--[[
+ 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
index 641f744..20af1f3 100644
--- a/packages/charm-sync/src/types.luau
+++ b/packages/charm-sync/src/types.luau
@@ -1,20 +1,21 @@
-local Charm = require(script.Parent.Parent.Charm)
-type Atom = Charm.Atom
-
---[=[
+--[[
A payload that can be sent from the server to the client to synchronize
- state between the two.
-]=]
-export type SyncPayload = {
- type: "init",
- data: { [string | number]: any },
-} | {
- type: "patch",
- data: { [string | number]: any },
-}
+ the states of the client's atoms with the server.
+]]
+export type SyncPayload =
+ | {
+ type: "init",
+ data: { [string]: any },
+ }
+ | {
+ type: "patch",
+ data: { [string]: any },
+ }
-export type AtomMap = {
- [string | number]: Atom,
-}
+--[[
+ 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" }
-return nil
+return {}
diff --git a/packages/charm-sync/src/utils.luau b/packages/charm-sync/src/utils.luau
new file mode 100644
index 0000000..fbf9260
--- /dev/null
+++ b/packages/charm-sync/src/utils.luau
@@ -0,0 +1,72 @@
+local Charm = require("../Charm")
+
+type Atom = Charm.Atom
+
+type NestedTable = {
+ [any]: NestedTable | any,
+}
+
+type FlattenedTable = {
+ [any]: any,
+}
+
+--[[
+ Flattens a nested table into a single table with slash-separated keys.
+ Useful for recursively collecting atoms returned by modules.
+
+ @param input A nested table.
+ @return A flattened table with slash-separated keys.
+]]
+local function flatten(input: NestedTable): FlattenedTable
+ local result: FlattenedTable = {}
+
+ local function visit(node: NestedTable, path: string)
+ for key, value in node do
+ local location = if path == "" then key else path .. "/" .. key
+
+ if type(value) == "table" then
+ visit(value :: NestedTable, location)
+ else
+ result[location] = value
+ end
+ end
+ end
+
+ visit(input, "")
+
+ return result
+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.
+]]
+local function deepCopy(object: T): T
+ local copy: any = table.clone(object :: any)
+ for key, value in copy do
+ if type(value) == "table" then
+ copy[key] = deepCopy(value)
+ end
+ end
+ return copy
+end
+
+return {
+ flatten = flatten,
+ signalToAtom = signalToAtom,
+ deepCopy = deepCopy,
+}
diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau
index c832542..c108c73 100644
--- a/packages/charm-sync/src/validate.luau
+++ b/packages/charm-sync/src/validate.luau
@@ -1,67 +1,48 @@
-local SAFE_KEYS = { string = true, number = true }
-local UNSAFE_VALUES = { ["function"] = true, thread = true }
-
local function isTableUnsafe(object: { [any]: any })
- local keyType = nil
- local objectSize = 0
+ local key = next(object)
- -- All keys must have the same type
- for key in next, object do
- local currentType = type(key)
-
- if not keyType and SAFE_KEYS[currentType] then
- keyType = currentType
- elseif keyType ~= currentType then
- return true
- end
-
- objectSize += 1
+ if key == nil then
+ return false
end
- -- If there are more keys than the length of the array, it's an array with
- -- non-sequential keys.
- if objectSize > #object and keyType == "number" then
+ local keyType = type(key)
+ local maxKey = 0
+
+ -- Keys must all be the same type
+ for key in object do
+ if type(key) ~= keyType then
+ return true
+ elseif keyType == "number" and key > maxKey then
+ maxKey = key
+ end
+ end
+
+ -- Array keys must be sequential
+ if keyType == "number" and maxKey > #object then
return true
end
return false
end
---[=[
- Validates a value to ensure it can be synced over a remote event.
+--[[
+ 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 value to validate.
- @param key The key of the value in the table.
- @error Throws an error if the value cannot be synced.
-]=]
-local function validate(value: any, key: any)
- local typeOfKey = type(key)
- local typeOfValue = type(value)
-
- if not SAFE_KEYS[typeOfKey] then
- error(`Invalid key type '{typeOfKey}' at key '{key}'`)
- elseif UNSAFE_VALUES[typeOfValue] then
- error(`Invalid value type '{typeOfValue}' at key '{key}'`)
- elseif typeOfValue == "table" then
- if getmetatable(value) ~= nil then
- error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
- elseif isTableUnsafe(value) then
- error(
- `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
- .. "This can be for the following reasons:\n"
- .. "1. The object is an array with non-sequential keys\n"
- .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
- .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
- )
- end
- end
-
- if typeOfKey == "number" then
- if key == math.huge or key == -math.huge then
- error("Cannot sync infinity as key")
- elseif key ~= math.floor(key) then
- error("Cannot sync non-integer number as key")
- end
+ @param value The table value.
+ @param key The key of the value.
+]]
+local function validate(value: { [any]: any })
+ if isTableUnsafe(value) then
+ warn(
+ `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
+ .. "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
diff --git a/packages/charm-sync/wally.toml b/packages/charm-sync/wally.toml
index f1a78f0..b6911a2 100644
--- a/packages/charm-sync/wally.toml
+++ b/packages/charm-sync/wally.toml
@@ -1,8 +1,15 @@
[package]
name = "littensy/charm-sync"
-version = "0.3.0"
+version = "0.4.0-rc.2"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
+exclude = [
+ "node_modules",
+ "src/__tests__",
+ "src/index.d.ts",
+ "package.json",
+ "Charm.luau",
+]
[dependencies]
-charm = "littensy/charm@^0.10"
+Charm = "littensy/charm@0.11.0-rc.2"
diff --git a/packages/charm/default.project.json b/packages/charm/default.project.json
index 89d3ada..a485e4d 100644
--- a/packages/charm/default.project.json
+++ b/packages/charm/default.project.json
@@ -1,5 +1,5 @@
{
- "name": "Charm",
+ "name": "charm",
"tree": {
"$path": "src"
}
diff --git a/packages/charm/package.json b/packages/charm/package.json
index ea40fa6..4bf25bf 100644
--- a/packages/charm/package.json
+++ b/packages/charm/package.json
@@ -1,11 +1,22 @@
{
"name": "@rbxts/charm",
- "version": "0.10.0",
- "description": "An atomic state management library for Roblox",
- "repository": "littensy/charm",
+ "version": "0.11.0-rc.2",
+ "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",
+ "!**/__tests__"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/littensy/charm.git"
+ },
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
diff --git a/packages/charm/src/__tests__/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau
new file mode 100644
index 0000000..3dd7284
--- /dev/null
+++ b/packages/charm/src/__tests__/computed.spec.luau
@@ -0,0 +1,106 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local computed = Charm.computed
+local effect = Charm.effect
+local signal = Charm.signal
+local test = suite.test
+
+test("should correctly propagate changes through computed core", function()
+ local src, setSrc = signal(0)
+ local c1 = computed(function()
+ return src() % 2
+ end)
+ local c2 = computed(function()
+ return c1()
+ end)
+ local c3 = computed(function()
+ return c2()
+ end)
+
+ c3()
+ setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
+ c2() -- c1 -> none, c2 -> none
+ setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
+
+ assert(c3() == 1, "bad final value")
+end)
+
+test("should propagate updated source value through chained computations", function()
+ local src, setSrc = signal(0)
+ local a = computed(function()
+ return src()
+ end)
+ local b = computed(function()
+ return a() % 2
+ end)
+ local c = computed(function()
+ return src()
+ end)
+ local d = computed(function()
+ return b() + c()
+ end)
+
+ assert(d() == 0, "bad initial value")
+ setSrc(2)
+ assert(d() == 2, "bad updated value")
+end)
+
+test("should handle flags are indirectly updated during checkDirty", function()
+ local a, setA = signal(false)
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ b()
+ return 0
+ end)
+ local d = computed(function()
+ c()
+ return b()
+ end)
+
+ assert(d() == false, "bad initial value")
+ setA(true)
+ assert(d() == true, "bad updated value")
+end)
+
+test("should not update if the signal value is reverted", function()
+ local times = 0
+
+ local src, setSrc = signal(0)
+ local c1 = computed(function()
+ times += 1
+ return src()
+ end)
+ c1()
+ assert(times == 1, "computed did not run")
+ setSrc(1)
+ setSrc(0)
+ c1()
+ assert(times == 1, "computed ran when it should not")
+end)
+
+test("should receive previous value", function()
+ local calls = {}
+ local src, setSrc = signal(0)
+ local c1 = computed(function(current): number
+ local incoming = src()
+ return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
+ end)
+
+ effect(function()
+ table.insert(calls, c1())
+ end)
+
+ setSrc(2)
+ setSrc(3)
+ assert(c1() == 2, "should skip odd value")
+
+ setSrc(4)
+ setSrc(5)
+ assert(c1() == 4, "should skip odd value")
+ assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau
new file mode 100644
index 0000000..56fe9c1
--- /dev/null
+++ b/packages/charm/src/__tests__/effect.spec.luau
@@ -0,0 +1,391 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local computed = Charm.computed
+local effect = Charm.effect
+local effectScope = Charm.effectScope
+local endBatch = Charm.endBatch
+local onCleanup = Charm.onCleanup
+local recursive = Charm.recursive
+local setActiveSub = Charm.setActiveSub
+local signal = Charm.signal
+local startBatch = Charm.startBatch
+local test = suite.test
+
+test("should clear subscriptions when untracked by all subscribers", function()
+ local bRunTimes = 0
+
+ local a, setA = signal(1)
+ local b = computed(function()
+ bRunTimes += 1
+ return a() * 2
+ end)
+ local stopEffect = effect(function()
+ b()
+ end)
+
+ assert(bRunTimes == 1, "should run initially")
+ setA(2)
+ assert(bRunTimes == 2, "should run after change")
+ stopEffect()
+ setA(3)
+ assert(bRunTimes == 2, "should not run after stop")
+end)
+
+test("should not run untracked inner effect", function()
+ local a, setA = signal(3)
+ local b = computed(function()
+ return a() > 0
+ end)
+
+ effect(function()
+ if b() then
+ effect(function()
+ if a() == 0 then
+ error("bad")
+ end
+ end)
+ end
+ end)
+
+ setA(2)
+ setA(1)
+ setA(0)
+end)
+
+test("should run outer effect first", function()
+ local a, setA = signal(1)
+ local b, setB = signal(1)
+
+ effect(function()
+ if a() > 0 then
+ effect(function()
+ b()
+ if a() == 0 then
+ error("bad")
+ end
+ end)
+ end
+ end)
+
+ startBatch()
+ setB(0)
+ setA(0)
+ endBatch()
+end)
+
+test("should not trigger inner effect when resolve maybe dirty", function()
+ local a, setA = signal(0)
+ local b = computed(function()
+ return a() % 2
+ end)
+
+ local innerTriggerTimes = 0
+
+ effect(function()
+ effect(function()
+ b()
+ innerTriggerTimes += 1
+ if innerTriggerTimes >= 2 then
+ error("bad")
+ end
+ end)
+ end)
+
+ setA(2)
+end)
+
+test("should notify inner effects in the same order as non-inner effects", function()
+ local getA, setA = signal(0)
+ local getB, setB = signal(0)
+ local c = computed(function()
+ return getA() - getB()
+ end)
+ local order1 = {}
+ local order2 = {}
+ local order3 = {}
+
+ effect(function()
+ table.insert(order1, "effect1")
+ getA()
+ end)
+ effect(function()
+ table.insert(order1, "effect2")
+ getA()
+ getB()
+ end)
+
+ effect(function()
+ c()
+ effect(function()
+ table.insert(order2, "effect1")
+ getA()
+ end)
+ effect(function()
+ table.insert(order2, "effect2")
+ getA()
+ getB()
+ end)
+ end)
+
+ effectScope(function()
+ effect(function()
+ table.insert(order3, "effect1")
+ getA()
+ end)
+ effect(function()
+ table.insert(order3, "effect2")
+ getA()
+ getB()
+ end)
+ end)
+
+ table.clear(order1)
+ table.clear(order2)
+ table.clear(order3)
+
+ startBatch()
+ setB(1)
+ setA(1)
+ endBatch()
+
+ assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
+ assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
+ assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
+end)
+
+test("should custom effect support batch", function()
+ local function batchEffect(fn: () -> ())
+ return effect(function()
+ startBatch()
+ local success, result: unknown = pcall(fn)
+ endBatch()
+ if not success then
+ error(result)
+ end
+ end)
+ end
+
+ local logs: { string } = {}
+ local a = signal(0)
+ local b, setB = signal(0)
+
+ local aa = computed(function()
+ table.insert(logs, "aa-0")
+ if a() == 0 then
+ setB(1)
+ end
+ table.insert(logs, "aa-1")
+ return nil
+ end)
+
+ local bb = computed(function()
+ table.insert(logs, "bb")
+ return b()
+ end)
+
+ batchEffect(function()
+ bb()
+ end)
+ batchEffect(function()
+ aa()
+ end)
+
+ assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
+end)
+
+test("should duplicate subscribers do not affect the notify order", function()
+ local src1, setSrc1 = signal(0)
+ local src2, setSrc2 = signal(0)
+ local order: { string } = {}
+
+ effect(function()
+ table.insert(order, "a")
+ local currentSub = setActiveSub(nil)
+ local isOne = src2() == 1
+ setActiveSub(currentSub)
+ if isOne then
+ src1()
+ end
+ src2()
+ src1()
+ end)
+ effect(function()
+ table.insert(order, "b")
+ src1()
+ end)
+ setSrc2(1) -- src1.subs: a -> b -> a
+
+ table.clear(order)
+ setSrc1(src1() + 1)
+
+ assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
+end)
+
+test("should handle side effect with inner effects", function()
+ local a, setA = signal(0)
+ local b, setB = signal(0)
+ local order: { string } = {}
+
+ effect(function()
+ effect(function()
+ a()
+ table.insert(order, "a")
+ end)
+ effect(function()
+ b()
+ table.insert(order, "b")
+ end)
+ assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
+
+ table.clear(order)
+ setB(1)
+ setA(1)
+ assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
+ end)
+end)
+
+test("should handle flags are indirectly updated during checkDirty", function()
+ local a, setA = signal(false)
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ b()
+ return 0
+ end)
+ local d = computed(function()
+ c()
+ return b()
+ end)
+
+ local triggers = 0
+
+ effect(function()
+ d()
+ triggers += 1
+ end)
+ assert(triggers == 1, "did not run initially")
+ setA(true)
+ assert(triggers == 2, "did not update indirectly")
+end)
+
+test("should handle effect recursion for the first execution", function()
+ local src1, setSrc1 = signal(0)
+ local src2, setSrc2 = signal(0)
+
+ local triggers1 = 0
+ local triggers2 = 0
+
+ effect(function()
+ triggers1 += 1
+ setSrc1(math.min(src1() + 1, 5))
+ end)
+ effect(function()
+ triggers2 += 1
+ setSrc2(math.min(src2() + 1, 5))
+ src2()
+ end)
+
+ assert(triggers1 == 1, `bad triggers1: {triggers1}`)
+ assert(triggers2 == 1, `bad triggers2: {triggers2}`)
+end)
+
+test("should opt in to recursion", function()
+ local src, setSrc = signal(0)
+ local order = {}
+
+ effect(function()
+ recursive()
+ table.insert(order, src())
+ setSrc(math.min(src() + 1, 5))
+ end)
+ assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
+end)
+
+test("should run cleanups in sequence", function()
+ local order = {}
+
+ local a, setA = signal(false)
+ local stopEffect = effect(function()
+ a()
+ onCleanup(function()
+ table.insert(order, "a")
+ end)
+ onCleanup(function()
+ table.insert(order, "b")
+ end)
+ end)
+
+ assert(table.concat(order) == "", "should not cleanup immediately")
+
+ setA(true)
+ assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
+
+ table.clear(order)
+ stopEffect()
+ assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
+
+ table.clear(order)
+ setA(false)
+ assert(table.concat(order) == "", "should not cleanup after update")
+end)
+
+test("should run inner effect cleanup", function()
+ local cleanups = 0
+
+ local src, setSrc = signal(false)
+ local stopEffect = effect(function()
+ src()
+ effect(function()
+ onCleanup(function()
+ cleanups += 1
+ end)
+ end)
+ effect(function()
+ onCleanup(function()
+ cleanups += 1
+ end)
+ end)
+ end)
+
+ assert(cleanups == 0, "should not cleanup immediately")
+ setSrc(true)
+ assert(cleanups == 2, "should cleanup after outer update")
+ stopEffect()
+ assert(cleanups == 4, "should cleanup after outer dispose")
+ setSrc(false)
+ assert(cleanups == 4, "should not cleanup after update")
+end)
+
+test("should run cleanups outside any evaluation context", function()
+ local calls = 0
+ local a, setA = signal(0)
+ local b, setB = signal(0)
+ local c = computed(function()
+ if a() == 0 then
+ effect(function()
+ onCleanup(function()
+ b()
+ end)
+ end)
+ end
+ return a()
+ end)
+
+ effect(function()
+ calls += 1
+ c()
+ end)
+ assert(calls == 1, "should run once")
+
+ calls = 0
+ setA(1)
+ assert(calls == 1, "should run once after a update")
+
+ calls = 0
+ setB(1)
+ assert(calls == 0, "should not run after b update")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/effectScope.spec.luau b/packages/charm/src/__tests__/effectScope.spec.luau
new file mode 100644
index 0000000..c484151
--- /dev/null
+++ b/packages/charm/src/__tests__/effectScope.spec.luau
@@ -0,0 +1,83 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local effect = Charm.effect
+local effectScope = Charm.effectScope
+local signal = Charm.signal
+local onCleanup = Charm.onCleanup
+local test = suite.test
+
+test("should not trigger after stop", function()
+ local count, setCount = signal(1)
+
+ local triggers = 0
+
+ local stopScope = effectScope(function()
+ effect(function()
+ triggers += 1
+ count()
+ end)
+ assert(triggers == 1, "did not run initially")
+
+ setCount(2)
+ assert(triggers == 2, "did not run after update")
+ end)
+
+ setCount(3)
+ assert(triggers == 3, "did not run after update outside scope")
+ stopScope()
+ setCount(4)
+ assert(triggers == 3, "ran after stop")
+end)
+
+test("should run indirect scope cleanup", function()
+ local cleanups = 0
+
+ local cleanup
+ local stopScope = effectScope(function()
+ cleanup = effectScope(function()
+ onCleanup(function()
+ cleanups += 1
+ end)
+ end)
+ end)
+
+ assert(cleanups == 0, "should not cleanup immediately")
+ stopScope()
+ assert(cleanups == 1, "should cleanup from parent dispose")
+ cleanup()
+ assert(cleanups == 1, "should skip redundant dispose")
+end)
+
+test("should run indirect effect cleanup", function()
+ local cleanups = 0
+
+ local a, setA = signal(false)
+ local stopScope = effectScope(function()
+ effect(function()
+ a()
+ onCleanup(function()
+ cleanups += 1
+ end)
+ end)
+ end)
+
+ assert(cleanups == 0, "should not cleanup immediately")
+ setA(true)
+ assert(cleanups == 1, "should cleanup once")
+ stopScope()
+ assert(cleanups == 2, "should cleanup after outer dispose")
+ setA(false)
+ assert(cleanups == 2, "should not cleanup after update")
+end)
+
+test("should disallow yielding", function()
+ local success = pcall(function()
+ effectScope(function()
+ (if task then task.wait else coroutine.yield :: never)()
+ end)
+ end)
+ assert(not success, "should error on yield")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau
new file mode 100644
index 0000000..34c8d4e
--- /dev/null
+++ b/packages/charm/src/__tests__/gc.spec.luau
@@ -0,0 +1,72 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local atom = Charm.atom
+local computed = Charm.computed
+local effect = Charm.effect
+local effectScope = Charm.effectScope
+local test = suite.test
+
+--[[
+ Creates a weak reference to a value.
+]]
+local function weakRef(value: T): { value: T? }
+ return setmetatable({ value = value }, { __mode = "v" }) :: any
+end
+
+--[[
+ Triggers garbage collection by creating a large number of tables.
+]]
+local function gc()
+ for _ = 1, 2000 do
+ local _ = table.create(100, 0)
+ end
+end
+
+--[[
+ In alien-signals, a computed value unlinks from dependencies when its last
+ parent scope is disposed. However, calling a computed value outside of a
+ scope still links it to dependencies, which act as strong references.
+ Computed values called in this way won't GC until unlinked from a scope.
+
+ This function releases a computed value by linking it to a scope and then
+ immediately closing it, causing alien-signals to unlink the computed value
+ from its dependencies and allow it to be garbage collected.
+]]
+local function unlink(getter: () -> ())
+ effectScope(function()
+ getter()
+ end)()
+end
+
+test("should release computed cache", function()
+ local srcRef = weakRef({})
+ local src = atom(srcRef.value)
+ local c = computed(function()
+ return src()
+ end)
+
+ c() -- cache src value
+ src(nil) -- release value
+ c = unlink(c) -- release computed
+
+ gc()
+ assert(srcRef.value == nil, "should release computed cache")
+end)
+
+test("should not release effect for atom", function()
+ local effectCalls = 0
+ local src = atom(0)
+
+ effect(function()
+ effectCalls += 1
+ src()
+ end)
+ assert(effectCalls == 1, "should call effect once")
+
+ gc()
+ src(src() + 1)
+ assert(effectCalls == 2, "should call effect again")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/listen.spec.luau b/packages/charm/src/__tests__/listen.spec.luau
new file mode 100644
index 0000000..f3098d1
--- /dev/null
+++ b/packages/charm/src/__tests__/listen.spec.luau
@@ -0,0 +1,118 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local listen = Charm.listen
+local effect = Charm.effect
+local test = suite.test
+
+test("should listen to selector", function()
+ local source, setSource = signal(0)
+ local current, previous
+
+ local unsub = listen(function()
+ return source() + 1
+ end, function(a, b)
+ current = a
+ previous = b
+ end)
+
+ assert(current == 1 and previous == nil, "should run initially")
+ setSource(0)
+ assert(current == 1 and previous == nil, "should skip redundant update")
+ setSource(1)
+ assert(current == 2 and previous == 1, "should run with new value")
+ unsub()
+ setSource(2)
+ assert(current == 2 and previous == 1, "should not run after unsub")
+end)
+
+test("should run in peek", function()
+ local runs = 0
+ local cleanups = 0
+ local a, setA = signal(0)
+ local b, setB = signal(0)
+
+ listen(a, function()
+ runs += 1
+ b()
+ effect(function()
+ return function()
+ cleanups += 1
+ end
+ end)
+ end)
+
+ assert(runs == 1, "should run once on init")
+ assert(cleanups == 0, "should not cleanup yet")
+
+ setA(1)
+ assert(runs == 2, "should run on a change")
+ assert(cleanups == 1, "should cleanup first call")
+
+ setB(1)
+ assert(runs == 2, "should not run on b change")
+ assert(cleanups == 1, "should not cleanup second call")
+end)
+
+test("should run if first value is nil", function()
+ local runs = 0
+ local source, setSource = signal(nil :: number?)
+
+ listen(source, function()
+ runs += 1
+ end)
+
+ assert(runs == 1, "should run once on init")
+ setSource(1)
+ assert(runs == 2, "should run once on change")
+end)
+
+test("should track inner subs", function()
+ local order = {}
+ local a, setA = signal(1)
+
+ local unsub = listen(a, function()
+ table.insert(order, 1)
+ listen(a, function()
+ table.insert(order, 2)
+ end)
+ listen(a, function()
+ table.insert(order, 3)
+ end)
+ end)
+
+ assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
+
+ table.clear(order)
+ setA(a() + 1)
+ assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
+
+ table.clear(order)
+ setA(a() + 1)
+ assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
+
+ table.clear(order)
+ unsub()
+ setA(a() + 1)
+ assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
+end)
+
+test("should skip redundant updates", function()
+ local order = {}
+ local a, setA = signal(0)
+
+ listen(function(): "even" | "odd"
+ return if a() % 2 == 0 then "even" else "odd"
+ end, function(value)
+ table.insert(order, value)
+ end)
+
+ setA(1)
+ setA(3)
+ setA(4)
+ setA(6)
+ assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/mapped.spec.luau b/packages/charm/src/__tests__/mapped.spec.luau
new file mode 100644
index 0000000..b812aea
--- /dev/null
+++ b/packages/charm/src/__tests__/mapped.spec.luau
@@ -0,0 +1,108 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local mapped = Charm.mapped
+local test = suite.test
+
+test("should map state", function()
+ local src, setSrc = signal({ 1, 2, 3 })
+ local map = mapped(src, function(value: number)
+ return value + 1
+ end)
+
+ assert(table.concat(map()) == "234", table.concat(map()))
+ setSrc({ 4, 5, 6 })
+ assert(table.concat(map()) == "567", table.concat(map()))
+end)
+
+test("should not map unchanged values", function()
+ local calls = 0
+
+ local src, setSrc = signal({ 1, 2, 3 })
+ local map = mapped(src, function(value: number)
+ calls += 1
+ return value + 1
+ end)
+
+ assert(table.concat(map()) == "234", table.concat(map()))
+ assert(calls == 3, "should map 3 values")
+
+ setSrc({ 1, 3, 3 })
+ assert(table.concat(map()) == "244", table.concat(map()))
+ assert(calls == 4, "should map 1 change")
+
+ setSrc({})
+ assert(#map() == 0, "should be empty")
+ assert(calls == 4, "should not map for removed values")
+end)
+
+test("should not update if no values change", function()
+ local calls = 0
+
+ local src, setSrc = signal({ 1, 2, 3 })
+ local map = mapped(src, function(value: number)
+ calls += 1
+ return value + 1
+ end)
+
+ local prev = map()
+ assert(calls == 3, "should map 3 values")
+ assert(table.concat(map()) == "234", table.concat(map()))
+
+ setSrc({ 4, 5, 6, 7 })
+ setSrc({ 1, 2, 3 })
+ map()
+ assert(calls == 3, "should not map unchanged values")
+ assert(table.concat(map()) == "234", table.concat(map()))
+ assert(prev == map(), "should not create new table")
+end)
+
+test("should filter values", function()
+ local calls = 0
+
+ local src, setSrc = signal({ a = 1, b = 2, c = 3 })
+ local map = mapped(src, function(value: number)
+ calls += 1
+ return if value < 0 then nil else value
+ end)
+
+ local state = map()
+ assert(calls == 3, "should map 3 values")
+ assert(state.a and state.b and state.c, "should map all values")
+
+ calls = 0
+ setSrc({ a = -1, b = 2, c = 3, d = 4 })
+ state = map()
+ assert(calls == 2, "should map 2 values")
+ assert(state.a == nil, "should remove a")
+ assert(state.b and state.c, "should keep b and c")
+ assert(state.d == 4, "should add d")
+end)
+
+test("should map keys", function()
+ local calls = 0
+
+ local src, setSrc = signal({ a = 1, b = 2, c = 3 })
+ local map = mapped(src, function(value: number, key: string)
+ calls += 1
+ return if value < 0 then nil else value, string.upper(key)
+ end)
+
+ local state = map()
+ assert(calls == 3, "should map 3 values")
+ assert(not (state.a and state.b and state.c), "should discard old keys")
+ assert(state.A == 1, "should map a")
+ assert(state.B == 2, "should map b")
+ assert(state.C == 3, "should map c")
+
+ calls = 0
+ setSrc({ a = -1, b = 2, c = 3, d = 4 })
+ state = map()
+ assert(calls == 2, "should map 2 values")
+ assert(state.A == nil and state.a == nil, "should remove a")
+ assert(state.B and state.C, "should keep b and c")
+ assert(state.D == 4, "should add d")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau
new file mode 100644
index 0000000..f308477
--- /dev/null
+++ b/packages/charm/src/__tests__/observe.spec.luau
@@ -0,0 +1,171 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local computed = Charm.computed
+local effect = Charm.effect
+local effectScope = Charm.effectScope
+local observe = Charm.observe
+local test = suite.test
+
+test("should observe items", function()
+ local items, setItems = signal({ 0, 0, 0 })
+ local updates = {}
+ local cleanups = {}
+
+ observe(items, function(_, key)
+ local get = computed(function()
+ return items()[key]
+ end)
+
+ effect(function()
+ table.insert(updates, get())
+ end)
+
+ return function()
+ table.insert(cleanups, key)
+ end
+ end)
+
+ assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
+
+ table.clear(updates)
+ setItems({ 1, 2, 3 })
+ assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
+
+ table.clear(updates)
+ setItems({ 1, 2, nil :: any, 4 })
+ assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
+ assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
+end)
+
+test("should dispose scopes", function()
+ local items, setItems = signal({ 1, 2, 3 })
+ local a, setA = signal(0)
+ local runs = {}
+ local disposes = {}
+
+ local dispose = observe(items, function(item)
+ effect(function()
+ table.insert(runs, item)
+ a()
+ end)
+ effectScope(function()
+ return function()
+ table.insert(disposes, item)
+ end
+ end)
+ end)
+
+ table.clear(runs)
+ setItems({ 1, 2 })
+ setA(1)
+ assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
+ assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
+
+ table.clear(runs)
+ table.clear(disposes)
+ dispose()
+ setA(2)
+ assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
+ assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
+end)
+
+test("should queue addition and removal", function()
+ local items, setItems = signal({ 1 })
+ local edits = {}
+
+ observe(items, function(item)
+ if item == 1 then
+ setItems({ 1, 2, 3, 4 })
+ elseif item == 4 then
+ setItems({ 1 })
+ end
+ table.insert(edits, `+{item}`)
+ return function()
+ table.insert(edits, `-{item}`)
+ end
+ end)
+
+ assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
+end)
+
+test("should handle recursive additions and removals", function()
+ local items, setItems = signal({ 1 })
+ local edits = {}
+
+ observe(items, function(item)
+ if item < 5 then
+ setItems(function(state)
+ state = table.clone(state)
+ table.insert(state, item + 1)
+ return state
+ end)
+ else
+ setItems({})
+ end
+ table.insert(edits, `+{item}`)
+ return function()
+ table.insert(edits, `-{item}`)
+ end
+ end)
+
+ assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
+end)
+
+test("should handle self removal", function()
+ local items, setItems = signal({ 1, 2, 3, 4 })
+ local edits = {}
+
+ observe(items, function(item, index)
+ setItems(function(state)
+ state = table.clone(state)
+ state[index] = nil
+ return state
+ end)
+ table.insert(edits, `+{item}`)
+ return function()
+ table.insert(edits, `-{item}`)
+ end
+ end)
+
+ assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
+end)
+
+test("should allow disposal during addition", function()
+ local items, setItems = signal({})
+ local edits = {}
+ local dispose
+
+ dispose = observe(items, function(item)
+ if item == 1 then
+ dispose()
+ end
+ table.insert(edits, `+{item}`)
+ return function()
+ table.insert(edits, `-{item}`)
+ end
+ end)
+
+ setItems({ 1, 2, 3, 4 })
+ assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`)
+end)
+
+test("should allow disposal during removal", function()
+ local items, setItems = signal({ 1, 2, 3, 4 })
+ local edits = {}
+ local dispose
+
+ dispose = observe(items, function(item)
+ table.insert(edits, `+{item}`)
+ return function()
+ dispose()
+ table.insert(edits, `-{item}`)
+ end
+ end)
+
+ setItems({ 1, 2, 3, nil :: any, 5 })
+ assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/peek.spec.luau b/packages/charm/src/__tests__/peek.spec.luau
new file mode 100644
index 0000000..b786135
--- /dev/null
+++ b/packages/charm/src/__tests__/peek.spec.luau
@@ -0,0 +1,147 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local computed = Charm.computed
+local effect = Charm.effect
+local peek = Charm.peek
+local test = suite.test
+
+test("should not run detached", function()
+ local a, setA = signal(1)
+ local spyCalls = 0
+
+ effect(function()
+ a()
+ peek(function()
+ effect(function()
+ return function()
+ spyCalls += 1
+ end
+ end)
+ end)
+ end)
+
+ assert(spyCalls == 0, "should not be called yet")
+ setA(2)
+ assert(spyCalls == 1, "should be called once")
+ setA(3)
+ assert(spyCalls == 2, "should be called twice")
+end)
+
+test("should block tracking inside effects", function()
+ local a, setA = signal(1)
+ local b, setB = signal(2)
+ local spyCalls = 0
+
+ effect(function()
+ peek(function()
+ spyCalls += 1
+ return a() + b()
+ end)
+ end)
+ assert(spyCalls == 1, "should be called once")
+
+ setA(10)
+ setB(20)
+ assert(spyCalls == 1, "should be called once")
+end)
+
+test("should block tracking even when run inside effect run inside peek", function()
+ local s, setS = signal(1)
+ local spyCalls = 0
+
+ peek(function()
+ effect(function()
+ peek(function()
+ spyCalls += 1
+ return s()
+ end)
+ end)
+ end)
+ assert(spyCalls == 1, "should be called once")
+
+ setS(2)
+ assert(spyCalls == 1, "should be called once")
+end)
+
+test("should not cause signal assignments to throw", function()
+ local a, setA = signal(1)
+ local aChangedTime, setAChangedTime = signal(0)
+
+ local dispose = effect(function()
+ a()
+ peek(function()
+ setAChangedTime(aChangedTime() + 1)
+ end)
+ end)
+
+ setA(2)
+ assert(aChangedTime() == 2, "aChangedTime should be 2")
+ setA(3)
+ assert(aChangedTime() == 3, "aChangedTime should be 3")
+
+ dispose()
+end)
+
+test("should block tracking inside computed signals", function()
+ local a, setA = signal(1)
+ local b, setB = signal(2)
+ local spyCalls = 0
+ local c = computed(function()
+ return peek(function()
+ spyCalls += 1
+ return a() + b()
+ end)
+ end)
+
+ assert(spyCalls == 0, "should not be called yet")
+ assert(c() == 3, "c should be 3")
+ setA(10)
+ c()
+ setB(20)
+ c()
+ assert(spyCalls == 1, "should be called once")
+ assert(c() == 3, "c should be 3")
+end)
+
+test("should track outside peek", function()
+ local a, setA = signal(1)
+ local b, setB = signal(2)
+ local c, setC = signal(3)
+ local spyCalls = 0
+
+ effect(function()
+ a()
+ peek(c)
+ b()
+ spyCalls += 1
+ end)
+
+ assert(spyCalls == 1, "should be called once")
+ setA(10)
+ assert(spyCalls == 2, "should be called twice")
+ setB(20)
+ assert(spyCalls == 3, "should be called three times")
+ setC(30)
+ assert(spyCalls == 3, "should not run on c change")
+end)
+
+test("should stack peek", function()
+ local a, setA = signal(1)
+ local spyCalls = 0
+
+ effect(function()
+ peek(function()
+ peek(a)
+ return a()
+ end)
+ spyCalls += 1
+ end)
+
+ assert(spyCalls == 1, "should be called once")
+ setA(2)
+ assert(spyCalls == 1, "should not be called twice")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/reactive.spec.luau b/packages/charm/src/__tests__/reactive.spec.luau
new file mode 100644
index 0000000..70237b9
--- /dev/null
+++ b/packages/charm/src/__tests__/reactive.spec.luau
@@ -0,0 +1,124 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local effect = Charm.effect
+local reactive = Charm.reactive
+local test = suite.test
+
+test("should track nested properties", function()
+ local src, setSrc = reactive({ a = { b = { c = 0 } } })
+ local calls = {}
+
+ effect(function()
+ table.insert(calls, `{src.a.b.c}`)
+ end)
+
+ setSrc(function(state)
+ state.a.b.c = 1
+ end)
+ src.a.b.c = 2
+ src.a.b.c = 2
+ setSrc()
+ assert(table.concat(calls) == "012", table.concat(calls))
+end)
+
+test("should track object type changes", function()
+ local src = reactive({ a = { b = 0 } } :: { a: any })
+ local calls = {}
+
+ effect(function()
+ table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
+ end)
+
+ src.a = { b = 0 }
+ src.a = 1
+ src.a = { b = 2 }
+ src.a = nil
+ src.a = 3
+ assert(table.concat(calls) == "0012nil3", table.concat(calls))
+end)
+
+test("should unwrap proxy assignments", function()
+ local src = reactive({ a = { x = 0 }, b = { x = 0 } })
+ local runs = 0
+
+ effect(function()
+ local _, _ = src.a.x, src.b.x
+ runs += 1
+ end)
+
+ src.a, src.b = src.a, src.b
+ assert(runs == 1, runs)
+
+ src.a, src.b = src.b, src.a
+ assert(runs == 3, runs)
+end)
+
+test("should track iteration", function()
+ local src, setSrc = reactive({} :: { [string]: number })
+ local calls = {}
+
+ effect(function()
+ for key, value in src do
+ table.insert(calls, `{key}={value};`)
+ end
+ end)
+
+ src.a = 1
+ assert(table.concat(calls) == "a=1;", table.concat(calls))
+
+ table.clear(calls)
+ src.b = 2
+ assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
+
+ table.clear(calls)
+ src.a = 2
+ assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
+
+ table.clear(calls)
+ setSrc()
+ assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
+end)
+
+test("should track array length", function()
+ local src = reactive({ array = {} :: { number } })
+ local calls = {}
+
+ effect(function()
+ table.insert(calls, #src.array)
+ end)
+
+ src.array[#src.array + 1] = 1
+ src.array[#src.array + 1] = 2
+ src.array[#src.array + 1] = 3
+ src.array = { 1, 2, 3, 4 }
+ assert(table.concat(calls) == "01234", table.concat(calls))
+end)
+
+test("should not track unread properties", function()
+ local src = reactive({ value = { a = 1, b = 2 } })
+ local calls = 0
+
+ effect(function()
+ local _ = src.value.a
+ calls += 1
+ end)
+ assert(calls == 1, calls)
+
+ src.value.b = 3
+ assert(calls == 1, calls)
+end)
+
+test("should overwrite with table passed to setter", function()
+ local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number })
+ local calls = {}
+
+ effect(function()
+ table.insert(calls, `{src.a}{src.b}{src.c}`)
+ end)
+
+ setSrc({ a = 2, c = 4 })
+ assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/signal.spec.luau b/packages/charm/src/__tests__/signal.spec.luau
new file mode 100644
index 0000000..a9693f6
--- /dev/null
+++ b/packages/charm/src/__tests__/signal.spec.luau
@@ -0,0 +1,50 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local effect = Charm.effect
+local test = suite.test
+
+test("should accept equality function", function()
+ local calls = {}
+ local src, setSrc = signal(0, function(current, incoming)
+ return current == incoming or incoming % 2 == 1
+ end)
+
+ effect(function()
+ table.insert(calls, src())
+ end)
+
+ setSrc(2)
+ setSrc(3)
+ assert(src() == 2, "should skip odd value")
+
+ setSrc(4)
+ setSrc(5)
+ assert(src() == 4, "should skip odd value")
+ assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
+end)
+
+test("should enforce immutability", function()
+ local src, setSrc = signal({ nested = {} })
+ assert(not pcall(function()
+ src().nested.a = 1
+ end), "should not allow mutation of initial value")
+
+ setSrc({ nested = { a = 1 } })
+ assert(not pcall(function()
+ src().nested.a = 2
+ end), "should not allow mutation of new value")
+end)
+
+test("should not freeze values with metatables", function()
+ local src = signal({ object = setmetatable({}, {}) })
+ assert(
+ pcall(function()
+ src().object.a = 2
+ end),
+ "should allow mutation of values with metatables"
+ )
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau
new file mode 100644
index 0000000..17dd1f2
--- /dev/null
+++ b/packages/charm/src/__tests__/spec.luau
@@ -0,0 +1,46 @@
+local suite = require("../../../../test/suite")
+
+suite.describe("computed", function()
+ require("./computed.spec")
+end)
+suite.describe("effect", function()
+ require("./effect.spec")
+end)
+suite.describe("effectScope", function()
+ require("./effectScope.spec")
+end)
+suite.describe("gc", function()
+ require("./gc.spec")
+end)
+suite.describe("listen", function()
+ require("./listen.spec")
+end)
+suite.describe("mapped", function()
+ require("./mapped.spec")
+end)
+suite.describe("observe", function()
+ require("./observe.spec")
+end)
+suite.describe("peek", function()
+ require("./peek.spec")
+end)
+suite.describe("reactive", function()
+ require("./reactive.spec")
+end)
+suite.describe("signal", function()
+ require("./signal.spec")
+end)
+suite.describe("subscribe", function()
+ require("./subscribe.spec")
+end)
+suite.describe("topology", function()
+ require("./topology.spec")
+end)
+suite.describe("trigger", function()
+ require("./trigger.spec")
+end)
+suite.describe("untracked", function()
+ require("./untracked.spec")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/subscribe.spec.luau b/packages/charm/src/__tests__/subscribe.spec.luau
new file mode 100644
index 0000000..938931b
--- /dev/null
+++ b/packages/charm/src/__tests__/subscribe.spec.luau
@@ -0,0 +1,134 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local computed = Charm.computed
+local subscribe = Charm.subscribe
+local listen = Charm.listen
+local effect = Charm.effect
+local test = suite.test
+
+test("should subscribe to selector", function()
+ local source, setSource = signal(0)
+ local current, previous
+
+ local unsub = subscribe(function()
+ return source() + 1
+ end, function(a, b)
+ current = a
+ previous = b
+ end)
+
+ assert(not current and not previous, "should not run initially")
+ setSource(0)
+ assert(not current and not previous, "should skip redundant update")
+ setSource(1)
+ assert(current == 2 and previous == 1, "should run with new value")
+ unsub()
+ setSource(2)
+ assert(current == 2 and previous == 1, "should not run after unsub")
+end)
+
+test("should run in peek", function()
+ local runs = 0
+ local cleanups = 0
+ local a, setA = signal(0)
+ local b, setB = signal(0)
+
+ subscribe(a, function()
+ runs += 1
+ b()
+ effect(function()
+ return function()
+ cleanups += 1
+ end
+ end)
+ end)
+
+ assert(runs == 0, "should not run once on init")
+ assert(cleanups == 0, "should not cleanup yet")
+
+ setA(1)
+ assert(runs == 1, "should run once on change")
+ assert(cleanups == 0, "should not cleanup yet")
+
+ setB(1)
+ assert(runs == 1, "should not run on b change")
+ assert(cleanups == 0, "should not cleanup first call")
+
+ setA(2)
+ assert(runs == 2, "should run on a change")
+ assert(cleanups == 1, "should cleanup first call")
+end)
+
+test("should ensure correct execution order", function()
+ local order = {}
+ local n1, setN1 = signal(0)
+ local n2, setN2 = signal(0)
+ local sum = computed(function()
+ return n1() + n2()
+ end)
+
+ subscribe(n1, function()
+ table.insert(order, 1)
+ setN2(n2() + 1)
+ end)
+
+ subscribe(sum, function()
+ table.insert(order, 2)
+ end)
+
+ subscribe(n1, function()
+ table.insert(order, 3)
+ end)
+
+ setN1(n1() + 1)
+
+ assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
+end)
+
+test("should track inner subs", function()
+ local order = {}
+ local a, setA = signal(1)
+
+ local unsub = subscribe(a, function()
+ table.insert(order, 1)
+ listen(a, function()
+ table.insert(order, 2)
+ end)
+ listen(a, function()
+ table.insert(order, 3)
+ end)
+ end)
+
+ setA(a() + 1)
+ assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
+
+ table.clear(order)
+ setA(a() + 1)
+ assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
+
+ table.clear(order)
+ unsub()
+ setA(a() + 1)
+ assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
+end)
+
+test("should skip redundant updates", function()
+ local order = {}
+ local a, setA = signal(0)
+
+ subscribe(function(): "even" | "odd"
+ return if a() % 2 == 0 then "even" else "odd"
+ end, function(value)
+ table.insert(order, value)
+ end)
+
+ setA(1)
+ setA(3)
+ setA(4)
+ setA(6)
+ assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/topology.spec.luau b/packages/charm/src/__tests__/topology.spec.luau
new file mode 100644
index 0000000..6f1e1e0
--- /dev/null
+++ b/packages/charm/src/__tests__/topology.spec.luau
@@ -0,0 +1,490 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local atom = Charm.atom
+local computed = Charm.computed
+local effect = Charm.effect
+local describe = suite.describe
+local test = suite.test
+
+--[[
+ Tests adopted with thanks from preact-core implementation at
+ https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
+
+ The MIT License (MIT)
+
+ Copyright (c) 2022-present Preact Team
+
+ 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
+]]
+
+describe("graph updates", function()
+ test("should drop A->B->A updates", function()
+ -- A
+ -- / |
+ -- B | <- Looks like a flag doesn't it? :D
+ -- \ |
+ -- C
+ -- |
+ -- D
+ local a = atom(2)
+
+ local b = computed(function()
+ return a() - 1
+ end)
+ local c = computed(function()
+ return a() + b()
+ end)
+
+ local computeCalls = 0
+ local compute = function()
+ computeCalls += 1
+ return "d: " .. c()
+ end
+ local d = computed(compute)
+
+ -- Trigger read
+ assert(d() == "d: 3", "d should be 3")
+ assert(computeCalls == 1, "compute should be called once")
+ computeCalls = 0
+
+ a(4)
+ d()
+ assert(computeCalls == 1, "compute should drop update")
+ end)
+
+ test("should only update every signal once (diamond graph)", function()
+ -- In this scenario "D" should only update once when "A" receives
+ -- an update. This is sometimes referred to as the "diamond" scenario.
+ -- A
+ -- / \
+ -- B C
+ -- \ /
+ -- D
+ local a = atom("a")
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ return a()
+ end)
+
+ local spyCalls = 0
+ local spy = function()
+ spyCalls += 1
+ return b() .. " " .. c()
+ end
+ local d = computed(spy)
+
+ assert(d() == "a a", "d should be 'a a'")
+ assert(spyCalls == 1, "compute should be called once")
+
+ a("aa")
+ assert(d() == "aa aa", "d should be 'aa aa'")
+ assert(spyCalls == 2, "compute should be called only twice")
+ end)
+
+ test("should only update every signal once (diamond graph + tail)", function()
+ -- "E" will be likely updated twice if our mark+sweep logic is buggy.
+ -- A
+ -- / \
+ -- B C
+ -- \ /
+ -- D
+ -- |
+ -- E
+ local a = atom("a")
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ return a()
+ end)
+
+ local d = computed(function()
+ return b() .. " " .. c()
+ end)
+
+ local spyCalls = 0
+ local spy = function()
+ spyCalls += 1
+ return d()
+ end
+ local e = computed(spy)
+
+ assert(e() == "a a", "e should be 'a a'")
+ assert(spyCalls == 1, "compute should be called once")
+
+ a("aa")
+ assert(e() == "aa aa", "e should be 'aa aa'")
+ assert(spyCalls == 2, "compute should be called only twice")
+ end)
+
+ test("should bail out if result is the same", function()
+ -- Bail out if value of "B" never changes
+ -- A->B->C
+ local a = atom("a")
+ local b = computed(function()
+ a()
+ return "foo"
+ end)
+
+ local spyCalls = 0
+ local spy = function()
+ spyCalls += 1
+ return b()
+ end
+ local c = computed(spy)
+
+ assert(c() == "foo", "c should be 'foo'")
+ assert(spyCalls == 1, "compute should be called once")
+
+ a("aa")
+ assert(c() == "foo", "c should be 'foo'")
+ assert(spyCalls == 1, "compute should bail out")
+ end)
+
+ test("should only update every signal once (jagged diamond graph + tails)", function()
+ -- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
+ -- A
+ -- / \
+ -- B C
+ -- | |
+ -- | D
+ -- \ /
+ -- E
+ -- / \
+ -- F G
+ local a = atom("a")
+
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ return a()
+ end)
+
+ local d = computed(function()
+ return c()
+ end)
+
+ local eSpyCalls = 0
+ local eSpyTime = 0
+ local eSpy = function()
+ eSpyCalls += 1
+ eSpyTime = os.clock()
+ return b() .. " " .. d()
+ end
+ local e = computed(eSpy)
+
+ local fSpyCalls = 0
+ local fSpyTime = 0
+ local fSpy = function()
+ fSpyCalls += 1
+ fSpyTime = os.clock()
+ return e()
+ end
+ local f = computed(fSpy)
+ local gSpyCalls = 0
+ local gSpyTime = 0
+ local gSpy = function()
+ gSpyCalls += 1
+ gSpyTime = os.clock()
+ return e()
+ end
+ local g = computed(gSpy)
+
+ assert(f() == "a a", "f should be 'a a'")
+ assert(fSpyCalls == 1, "f should be called once")
+
+ assert(g() == "a a", "g should be 'a a'")
+ assert(gSpyCalls == 1, "g should be called once")
+
+ eSpyCalls, eSpyTime = 0, 0
+ fSpyCalls, fSpyTime = 0, 0
+ gSpyCalls, gSpyTime = 0, 0
+
+ a("b")
+
+ assert(e() == "b b", "e should be 'b b'")
+ assert(eSpyCalls == 1, "e should run once after update")
+
+ assert(f() == "b b", "f should be 'b b'")
+ assert(fSpyCalls == 1, "f should run once after update")
+
+ assert(g() == "b b", "g should be 'b b'")
+ assert(gSpyCalls == 1, "g should run once after update")
+
+ eSpyCalls, eSpyTime = 0, 0
+ fSpyCalls, fSpyTime = 0, 0
+ gSpyCalls, gSpyTime = 0, 0
+
+ a("c")
+
+ assert(e() == "c c", "e should be 'c c'")
+ assert(eSpyCalls == 1, "e should run once after update")
+
+ assert(f() == "c c", "f should be 'c c'")
+ assert(fSpyCalls == 1, "f should run once after update")
+
+ assert(g() == "c c", "g should be 'c c'")
+ assert(gSpyCalls == 1, "g should run once after update")
+
+ -- top to bottom
+ assert(eSpyTime < fSpyTime, "e should be called before f")
+ -- left to right
+ assert(eSpyTime < gSpyTime, "e should be called before g")
+ end)
+
+ test("should only subscribe to core listened to", function()
+ -- *A
+ -- / \
+ -- *B C <- we don't listen to C
+ local a = atom("a")
+
+ local b = computed(function()
+ return a()
+ end)
+ local spyCalls = 0
+ local spy = function()
+ spyCalls += 1
+ return a()
+ end
+ computed(spy)
+
+ assert(b() == "a", "b should be 'a'")
+ assert(spyCalls == 0, "compute should not run")
+
+ a("aa")
+ assert(b() == "aa", "b should be 'aa'")
+ assert(spyCalls == 0, "compute should not run")
+ end)
+
+ test("should only subscribe to core listened to II", function()
+ -- Here both "B" and "C" are active in the beginning, but
+ -- "B" becomes inactive later. At that point it should
+ -- not receive any updates anymore.
+ -- *A
+ -- / \
+ -- *B D <- we don't listen to C
+ -- |
+ -- *C
+ local a = atom("a")
+ local spyBCalls = 0
+ local spyB = function()
+ spyBCalls += 1
+ return a()
+ end
+ local b = computed(spyB)
+
+ local spyCCalls = 0
+ local spyC = function()
+ spyCCalls += 1
+ return b()
+ end
+ local c = computed(spyC)
+
+ local d = computed(function()
+ return a()
+ end)
+
+ local result = ""
+ local unsub = effect(function()
+ result = c()
+ end)
+
+ assert(result == "a", "result should be 'a'")
+ assert(d() == "a", "d should be 'a'")
+
+ spyBCalls = 0
+ spyCCalls = 0
+ unsub()
+
+ a("aa")
+
+ assert(spyBCalls == 0, "b should not run")
+ assert(spyCCalls == 0, "c should not run")
+ assert(d() == "aa", "d should be 'aa'")
+ end)
+
+ test("should ensure subs update even if one dep unmarks it", function()
+ -- In this scenario "C" always returns the same value. When "A"
+ -- changes, "B" will update, then "C" at which point its update
+ -- to "D" will be unmarked. But "D" must still update because
+ -- "B" marked it. If "D" isn't updated, then we have a bug.
+ -- A
+ -- / \
+ -- B *C <- returns same value every time
+ -- \ /
+ -- D
+ local a = atom("a")
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ a()
+ return "c"
+ end)
+ local spyResult: string?
+ local spy = function()
+ spyResult = b() .. " " .. c()
+ return spyResult
+ end
+ local d = computed(spy)
+
+ assert(d() == "a c", "d should be 'a c'")
+ spyResult = nil
+
+ a("aa")
+ d()
+ assert(spyResult == "aa c", "d should have returned 'aa c'")
+ end)
+
+ test("should ensure subs update even if two deps unmark it", function()
+ -- In this scenario both "C" and "D" always return the same
+ -- value. But "E" must still update because "A" marked it.
+ -- If "E" isn't updated, then we have a bug.
+ -- A
+ -- / | \
+ -- B *C *D
+ -- \ | /
+ -- E
+ local a = atom("a")
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ a()
+ return "c"
+ end)
+ local d = computed(function()
+ a()
+ return "d"
+ end)
+ local spyResult: string?
+ local spy = function()
+ spyResult = b() .. " " .. c() .. " " .. d()
+ return spyResult
+ end
+ local e = computed(spy)
+
+ assert(e() == "a c d", "e should be 'a c d'")
+ spyResult = nil
+
+ a("aa")
+ e()
+ assert(spyResult == "aa c d", "e should have returned 'aa c d'")
+ end)
+
+ test("should support lazy branches", function()
+ local a = atom(0)
+ local b = computed(function()
+ return a()
+ end)
+ local c = computed(function()
+ return if a() > 0 then a() else b()
+ end)
+
+ assert(c() == 0, "c should be 0")
+ a(1)
+ assert(c() == 1, "c should be 1")
+
+ a(0)
+ assert(c() == 0, "c should be 0 again")
+ end)
+
+ test("should not update a sub if all deps unmark it", function()
+ -- In this scenario "B" and "C" always return the same value. When "A"
+ -- changes, "D" should not update.
+ -- A
+ -- / \
+ -- *B *C
+ -- \ /
+ -- D
+ local a = atom("a")
+ local b = computed(function()
+ a()
+ return "b"
+ end)
+ local c = computed(function()
+ a()
+ return "c"
+ end)
+ local spyCalls = 0
+ local spy = function()
+ spyCalls += 1
+ return b() .. " " .. c()
+ end
+ local d = computed(spy)
+
+ assert(d() == "b c", "d should be 'b c'")
+ spyCalls = 0
+
+ a("aa")
+ assert(spyCalls == 0, "d should not run")
+ end)
+end)
+
+describe("error handling", function()
+ test("should keep graph consistent on errors during activation", function()
+ local a = atom(0)
+ local b = computed(function()
+ error("fail")
+ end)
+ local c = computed(function()
+ return a()
+ end)
+
+ local success, result = pcall(function()
+ b()
+ end)
+ assert(not success, "should throw")
+ assert(string.match(result, "fail"), "should throw fail")
+
+ a(1)
+ assert(c() == 1, "c should recover and return 1")
+ end)
+
+ test("should keep graph consistent on errors in computeds", function()
+ local a = atom(0)
+ local b = computed(function()
+ if a() == 1 then
+ error("fail")
+ end
+ return a()
+ end)
+ local c = computed(function()
+ return b()
+ end)
+
+ assert(c() == 0, "c should be 0")
+
+ a(1)
+ local success, result = pcall(function()
+ b()
+ end)
+ assert(not success, "should throw")
+ assert(string.match(result, "fail"), "should throw fail")
+
+ a(2)
+ assert(c() == 2, "c should recover and return 2")
+ end)
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/trigger.spec.luau b/packages/charm/src/__tests__/trigger.spec.luau
new file mode 100644
index 0000000..4055cbe
--- /dev/null
+++ b/packages/charm/src/__tests__/trigger.spec.luau
@@ -0,0 +1,85 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local computed = Charm.computed
+local effect = Charm.effect
+local signal = Charm.signal
+local subscribe = Charm.subscribe
+local trigger = Charm.trigger
+local test = suite.test
+local beforeEach = suite.beforeEach
+local afterEach = suite.afterEach
+
+beforeEach(function()
+ Charm.flags.frozen = false
+end)
+
+afterEach(function()
+ Charm.flags.frozen = true
+end)
+
+test("should not throw when triggering with no dependencies", function()
+ trigger(function() end)
+end)
+
+test("should trigger updates for dependent computed signals", function()
+ local arr = signal({} :: { number })
+ local length = computed(function()
+ return #arr()
+ end)
+
+ assert(length() == 0, "initial length should be 0")
+ table.insert(arr(), 1)
+ trigger(arr)
+ assert(length() == 1, "length should be 1")
+end)
+
+test("should trigger updates for the second source signal", function()
+ local src1 = signal({} :: { number })
+ local src2 = signal({} :: { number })
+ local length = computed(function()
+ return #src2()
+ end)
+
+ assert(length() == 0, "initial length should be 0")
+ table.insert(src2(), 1)
+ trigger(function()
+ src1()
+ src2()
+ end)
+ assert(length() == 1, "length should be 1")
+end)
+
+test("should trigger effect once", function()
+ local src1 = signal({} :: { number })
+ local src2 = signal({} :: { number })
+ local triggers = 0
+
+ effect(function()
+ triggers += 1
+ src1()
+ src2()
+ end)
+ assert(triggers == 1, "effect should run once initially")
+
+ trigger(function()
+ src1()
+ src2()
+ end)
+ assert(triggers == 2, "effect should run twice")
+end)
+
+test("should trigger updates for subscribe", function()
+ local src = signal({} :: { number })
+ local triggers = 0
+
+ subscribe(src, function()
+ triggers += 1
+ end)
+
+ table.insert(src(), 1)
+ trigger(src)
+ assert(triggers == 1, "subscribe callback should run after trigger")
+end)
+
+return {}
diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau
new file mode 100644
index 0000000..d4bfc4c
--- /dev/null
+++ b/packages/charm/src/__tests__/untracked.spec.luau
@@ -0,0 +1,177 @@
+local Charm = require("../")
+local suite = require("../../../../test/suite")
+
+local signal = Charm.signal
+local computed = Charm.computed
+local effect = Charm.effect
+local effectScope = Charm.effectScope
+local untracked = Charm.untracked
+local test = suite.test
+
+test("should run detached from effect", function()
+ local a, setA = signal(1)
+ local effects = 0
+ local innerEffects = {}
+
+ local stopEffect = effect(function()
+ a()
+ untracked(function()
+ local stopInnerEffect = effect(function()
+ effects += 1
+ return function()
+ effects -= 1
+ end
+ end)
+ table.insert(innerEffects, stopInnerEffect)
+ end)
+ end)
+
+ assert(effects == 1, "should have one effect")
+ setA(2)
+ assert(effects == 2, "should have two effects")
+ stopEffect()
+ assert(effects == 2, "should still have two effects")
+ for _, stopInnerEffect in innerEffects do
+ stopInnerEffect()
+ end
+ assert(effects == 0, "should have zero effects")
+end)
+
+test("should run detached from effect scope", function()
+ local effects = 0
+ local stopEffect
+
+ local stopScope = effectScope(function()
+ untracked(function()
+ stopEffect = effect(function()
+ effects += 1
+ return function()
+ effects -= 1
+ end
+ end)
+ end)
+ end)
+
+ assert(effects == 1, "should have one effect")
+ stopScope()
+ assert(effects == 1, "should still have one effect")
+ stopEffect()
+ assert(effects == 0, "should have zero effects")
+end)
+
+test("should block tracking inside effects", function()
+ local a, setA = signal(1)
+ local b, setB = signal(2)
+ local spyCalls = 0
+
+ effect(function()
+ untracked(function()
+ spyCalls += 1
+ return a() + b()
+ end)
+ end)
+ assert(spyCalls == 1, "should be called once")
+
+ setA(10)
+ setB(20)
+ assert(spyCalls == 1, "should be called once")
+end)
+
+test("should block tracking even when run inside effect run inside untracked", function()
+ local s, setS = signal(1)
+ local spyCalls = 0
+
+ untracked(function()
+ effect(function()
+ untracked(function()
+ spyCalls += 1
+ return s()
+ end)
+ end)
+ end)
+ assert(spyCalls == 1, "should be called once")
+
+ setS(2)
+ assert(spyCalls == 1, "should be called once")
+end)
+
+test("should not cause signal assignments to throw", function()
+ local a, setA = signal(1)
+ local aChangedTime, setAChangedTime = signal(0)
+
+ local dispose = effect(function()
+ a()
+ untracked(function()
+ setAChangedTime(aChangedTime() + 1)
+ end)
+ end)
+
+ setA(2)
+ assert(aChangedTime() == 2, "aChangedTime should be 2")
+ setA(3)
+ assert(aChangedTime() == 3, "aChangedTime should be 3")
+
+ dispose()
+end)
+
+test("should block tracking inside computed signals", function()
+ local a, setA = signal(1)
+ local b, setB = signal(2)
+ local spyCalls = 0
+ local c = computed(function()
+ return untracked(function()
+ spyCalls += 1
+ return a() + b()
+ end)
+ end)
+
+ assert(spyCalls == 0, "should not be called yet")
+ assert(c() == 3, "c should be 3")
+ setA(10)
+ c()
+ setB(20)
+ c()
+ assert(spyCalls == 1, "should be called once")
+ assert(c() == 3, "c should be 3")
+end)
+
+test("should track outside untracked", function()
+ local a, setA = signal(1)
+ local b, setB = signal(2)
+ local c, setC = signal(3)
+ local spyCalls = 0
+
+ effect(function()
+ a()
+ untracked(c)
+ b()
+ spyCalls += 1
+ end)
+
+ assert(spyCalls == 1, "should be called once")
+ setA(10)
+ assert(spyCalls == 2, "should be called twice")
+ setB(20)
+ assert(spyCalls == 3, "should be called three times")
+ setC(30)
+ assert(spyCalls == 3, "should not run on c change")
+end)
+
+test("should stack untracked", function()
+ local a, setA = signal(1)
+ local spyCalls = 0
+
+ effect(function()
+ untracked(function()
+ untracked(a)
+ return a()
+ end)
+ spyCalls += 1
+ end)
+
+ assert(spyCalls == 1, "should be called once")
+ setA(2)
+ assert(spyCalls == 1, "should not be called twice")
+end)
+
+return {}
diff --git a/packages/charm/src/atom.luau b/packages/charm/src/atom.luau
deleted file mode 100644
index f10dbd6..0000000
--- a/packages/charm/src/atom.luau
+++ /dev/null
@@ -1,42 +0,0 @@
-local store = require(script.Parent.store)
-local types = require(script.Parent.types)
-type Atom = types.Atom
-type AtomOptions = types.AtomOptions
-
---[=[
- Creates a new atom with the given state.
-
- @param state The initial state.
- @param options Optional configuration.
- @return A new atom.
-]=]
-local function atom(state: T, options: AtomOptions?): Atom
- local equals = options and options.equals
-
- local function atom(...)
- if select("#", ...) == 0 then
- local index = store.capturing.index
-
- if index > 0 then
- store.capturing.stack[index][atom] = true
- end
-
- return state
- end
-
- local nextState = store.peek(..., state)
-
- if state ~= nextState and not (equals and equals(state, nextState)) then
- state = nextState
- store.notify(atom)
- end
-
- return state
- end
-
- store.listeners[atom] = setmetatable({}, { __mode = "v" })
-
- return atom
-end
-
-return atom
diff --git a/packages/charm/src/computed.luau b/packages/charm/src/computed.luau
deleted file mode 100644
index e786c45..0000000
--- a/packages/charm/src/computed.luau
+++ /dev/null
@@ -1,37 +0,0 @@
-local atom = require(script.Parent.atom)
-local store = require(script.Parent.store)
-local types = require(script.Parent.types)
-type AtomOptions = types.AtomOptions
-type Selector = types.Selector
-
---[=[
- Creates a read-only atom that derives its state from one or more atoms.
- Used to avoid unnecessary recomputations if multiple listeners depend on
- the same atoms.
-
- @param callback The function that produces the state.
- @param options Optional configuration.
- @return A new read-only atom.
-]=]
-local function computed(callback: Selector, options: AtomOptions?): Selector
- local dependencies, state = store.capture(callback)
- local computedAtom = atom(state, options)
- local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
-
- local function listener()
- local computedAtom = computedRef.current
-
- if computedAtom then
- store.disconnect(dependencies, listener)
- dependencies, state = store.capture(callback)
- store.connect(dependencies, listener, computedAtom)
- computedAtom(state)
- end
- end
-
- store.connect(dependencies, listener, computedAtom)
-
- return computedAtom
-end
-
-return computed
diff --git a/packages/charm/src/effect.luau b/packages/charm/src/effect.luau
deleted file mode 100644
index 23f3294..0000000
--- a/packages/charm/src/effect.luau
+++ /dev/null
@@ -1,53 +0,0 @@
-local store = require(script.Parent.store)
-
-type Cleanup = () -> ()
-
---[=[
- Runs the given callback immediately and whenever any atom it depends on
- changes. Returns a cleanup function that unsubscribes the callback.
-
- @param callback The function to run.
- @return A function that unsubscribes the callback.
-]=]
-local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup
- local dependencies = {}
- local cleanup: Cleanup?
- local disconnected = false
- local disconnect
-
- local function listener()
- if cleanup then
- cleanup()
- end
-
- store.disconnect(dependencies, listener)
- dependencies, cleanup = store.capture(callback, disconnect)
-
- if not disconnected then
- store.connect(dependencies, listener)
- end
- end
-
- function disconnect()
- if disconnected then
- return
- end
-
- disconnected = true
- store.disconnect(dependencies, listener)
-
- if cleanup then
- cleanup()
- end
- end
-
- dependencies, cleanup = store.capture(callback, disconnect)
-
- if not disconnected then
- store.connect(dependencies, listener)
- end
-
- return disconnect
-end
-
-return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup
diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts
index da1c5fa..973b458 100644
--- a/packages/charm/src/index.d.ts
+++ b/packages/charm/src/index.d.ts
@@ -1,187 +1,319 @@
-export = Charm;
-export as namespace Charm;
-
+type Key = string | number | symbol;
type Cleanup = () => void;
-declare namespace Charm {
- /**
- * A primitive state container that can be read from or written to. When
- * the state changes, all subscribers are notified.
- *
- * @param state The next state or a function that produces the next state.
- * @returns The current state.
- */
- interface Atom extends Selector {
- (nextState: State | ((prevState: State) => State)): State;
- }
-
- /**
- * A function that depends on one or more atoms and produces a value. Can be
- * used to derive state from atoms.
- *
- * @returns The current state.
- */
- type Selector = () => State;
-
- /**
- * A function that depends on one or more atoms and produces a value. Can be
- * used to derive state from atoms.
- *
- * @deprecated Use `Selector` instead.
- * @returns The current state.
- */
- type Molecule = Selector;
-
- /**
- * Infers the type of the state produced by the given function.
- */
- type StateOf = T extends Selector ? State : never;
-
- interface AtomOptions {
- /**
- * A function that determines whether the state has changed. By default,
- * a strict equality check (`===`) is used.
- */
- equals?: (prev: State, next: State) => boolean;
- }
-
- /**
- * Creates a new atom with the given state.
- *
- * @param state The initial state.
- * @param options Optional configuration.
- * @returns A new atom.
- */
- function atom(state: State, options?: AtomOptions): Atom;
-
- // Overload for no arguments
- function atom(state?: State, options?: AtomOptions): Atom;
-
- /**
- * Creates a read-only atom that derives its state from one or more atoms.
- * Used to avoid unnecessary recomputations if multiple listeners depend on
- * the same atoms.
- *
- * @param callback The function that produces the state.
- * @param options Optional configuration.
- * @returns A new read-only atom.
- */
- function computed(callback: Selector, options?: AtomOptions): Selector;
-
- /**
- * Subscribes to changes in the given atom or selector. The callback is
- * called with the current state and the previous state immediately after a
- * change occurs.
- *
- * @param callback The atom or selector to subscribe to.
- * @param listener The function to call when the state changes.
- * @returns A function that unsubscribes the callback.
- */
- function subscribe(callback: Selector, listener: (state: State, prev: State) => void): Cleanup;
-
- /**
- * Runs the given callback immediately and whenever any atom it depends on
- * changes. Returns a cleanup function that unsubscribes the callback.
- *
- * @param callback The function to run.
- * @returns A function that unsubscribes the callback.
- */
- function effect(callback: () => Cleanup | void): Cleanup;
-
- /**
- * Returns the result of the function without subscribing to changes. If a
- * non-function value is provided, it is returned as is.
- *
- * @param callback The atom or selector to get the state of.
- * @param args Arguments to pass to the function.
- * @returns The current state.
- */
- function peek(callback: State | ((...args: Args) => State), ...args: Args): State;
-
- /**
- * Returns whether the given value is an atom.
- *
- * @param value The value to check.
- * @returns `true` if the value is an atom, otherwise `false`.
- */
- function isAtom(value: unknown): value is Atom;
-
- /**
- * Runs the given function and schedules listeners to be notified only once
- * after the function has completed. Useful for batching multiple changes.
- *
- * @param callback The function to run.
- */
- function batch(callback: () => void): void;
-
- /**
- * Captures all atoms that are read during the function call and returns them
- * along with the result of the function. Useful for tracking dependencies.
- *
- * @param callback The function to run.
- * @returns A tuple containing the captured atoms and the result of the function.
- */
- function capture(callback: Selector): LuaTuple<[dependencies: Set>, state: State]>;
-
- /**
- * Notifies all subscribers of the given atom that the state has changed.
- * Mainly for running effects, since subscriptions check for equality.
- *
- * @param atom The atom to notify.
- */
- function notify(atom: Atom): void;
-
- /**
- * Creates an instance of `factory` for each item in the atom's state, and
- * cleans up the instance when the item is removed. Returns a cleanup function
- * that unsubscribes all instances.
- *
- * @param callback The atom or selector to observe.
- * @param factory The function that tracks the lifecycle of each item.
- * @returns A function that unsubscribes all instances.
- */
- function observe- (
- callback: Selector,
- factory: (item: Item, index: number) => Cleanup | void,
- ): Cleanup;
-
- function observe(
- callback: Selector