feat: finalize sync docs

This commit is contained in:
littensy 2025-11-03 00:12:06 -08:00
parent 37f79ecc3f
commit ea40833d3b
15 changed files with 400 additions and 206 deletions

248
README.md
View file

@ -20,11 +20,11 @@
**Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic. **Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/alien-signals) signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic.
**Charm has a few core principles:** **Charm works under a few core principles.**
- Manage state with reactive signals: state containers that hold a value - Manage state with reactive signals: state containers that hold a value
- React to state updates: reading a signal automatically subscribes to it - React to state updates: reading a signal automatically subscribes to it
- Combine multiple signals: derive a new value from existing state that stays up-to-date - 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 - Fine-grained reactivity: make targeted updates in response to specific changes in the state
- Use immutable data: values are compared directly (`==`) to optimize change detection - Use immutable data: values are compared directly (`==`) to optimize change detection
@ -34,6 +34,28 @@
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
- https://angular.dev/guide/signals - https://angular.dev/guide/signals
<details>
<summary><b>Table of Contents</b></summary>
- [Installation](#installation)
- [Reference](#reference)
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
- [`computed(getter)`](#computedgetter)
- [`effect(callback)`](#effectcallback)
- [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- [`batched(callback)`](#batchedcallback)
- [`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)
- [Examples](#examples)
</details>
## At a Glance ## At a Glance
```luau ```luau
@ -104,7 +126,6 @@ setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
setQuery("^Buy") setQuery("^Buy")
``` ```
</summary>
</details> </details>
## Installation ## Installation
@ -122,7 +143,7 @@ Charm = "littensy/charm@VERSION"
--- ---
## Guide ## Reference
### `signal(initialValue, equals?)` ### `signal(initialValue, equals?)`
@ -143,12 +164,13 @@ Accessing the signal's value in an effect or computed signal will subscribe to i
You can also pass a custom equality function to only update the signal if the new value is _not_ equal to the current value: 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 ```luau
local getMax, setMax = signal(0, function(incoming, current) local getMax, setMax = signal(0, function(current, incoming)
return incoming <= current return incoming <= current
end) end)
setMax(1) --> 1 setMax(1) --> 1
setMax(-1) --> 1 setMax(2) --> 2
setMax(-1) --> 2
``` ```
> [!NOTE] > [!NOTE]
@ -223,9 +245,6 @@ dispose() --> Cleanup 1
Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like `subscribe()` and `observe()`. 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()`.
> [!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 `globals.trackInnerEffects` flag to assist with debugging.
```luau ```luau
local getCounter, setCounter = signal(0) local getCounter, setCounter = signal(0)
@ -240,11 +259,14 @@ setCounter(1) --> Outer: 1, Inner: 1
setCounter(2) --> Outer: 2, Inner: 2 setCounter(2) --> Outer: 2, Inner: 2
``` ```
> [!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 `globals.trackInnerEffects` flag to assist with debugging.
--- ---
### `untracked(callback)` ### `untracked(callback)`
In case you want to read signals that you don't want to subscribe to, you can use `untracked()` to essentially call a function _outside_ the current effect, preventing signals and effects in the callback from being tracked. In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked.
```luau ```luau
local getCounter, setCounter = signal(0) local getCounter, setCounter = signal(0)
@ -283,10 +305,11 @@ disposeInner() --> Cleaned up inner effect
### `peek(callback)` ### `peek(callback)`
Similar to `untracked()`, but does not prevent effects created by the callback from automatically disposing. If your callback reads a signal and creates effects, only the signals get untracked. 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 ```luau
local getCounter, setCounter = signal(0) local getCounter, setCounter = signal(0)
local disposeOuter = effect(function() local disposeOuter = effect(function()
peek(function() peek(function()
print(`Count is {getCounter()}`) print(`Count is {getCounter()}`)
@ -306,7 +329,7 @@ disposeOuter() --> Cleaned up inner effect
### `batched(callback)` ### `batched(callback)`
Combines multiple signal updates made by the callback into a single commit that gets triggered when the callback completes. Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
```luau ```luau
local getName, setName = signal("John") local getName, setName = signal("John")
@ -435,7 +458,7 @@ dispose() --> Cleanup a = 1
### `mapped(getter, mapper)` ### `mapped(getter, mapper)`
The `mapped` function iterates over every key in a table and uses the mapper to generate a new key and value. The resulting table is returned in 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 `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: The first value returned by the mapper is used as the new value:
@ -500,7 +523,7 @@ 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: 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 ```luau
local max = atom(0, function(incoming, current) local max = atom(0, function(current, incoming)
return incoming <= current return incoming <= current
end) end)
@ -510,6 +533,203 @@ max(-1) --> 1
--- ---
### `globals`
Global flags that customize the behavior of Charm. The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio.
| Flag | Default | Description |
| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. |
| frozen | `true` in Studio | Enforces 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. Should only be disabled to debug issues during migration. |
---
## Client-server sync
### Installation
```sh
npm install @rbxts/charm-sync
yarn add @rbxts/charm-sync
pnpm add @rbxts/charm-sync
```
```toml
[dependencies]
CharmSync = "littensy/charm-sync@VERSION"
```
### Quick start
Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals:
```luau
-- nameStore
local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe")
return {
getName = getName,
setName = setName,
getSurname = getSurname,
setSurname = setSurname,
}
```
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.
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.
> [!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.
```luau
local function onPlayerAdded(player: Player)
-- Add signal getters, computed signals, or atoms
server.addSignalsToClient(player, {
name = nameStore.getName,
surname = nameStore.getSurname,
})
end
for _, player in Players:GetPlayers() do
onPlayerAdded(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(function(player)
server.removeClient(player)
end)
server.connect(function(player, updates)
-- Customize how you send state updates to clients
syncEvent:FireClient(player, updates)
end)
```
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.hydrate` to patch the client's signals with the incoming state updates.
```luau
-- Add signal setters or atoms
client.addSignals({
name = nameStore.setName,
surname = nameStore.setSurname,
})
-- Update client signals when receiving updates from the server
syncEvent.OnClientEvent:Connect(function(updates)
client.hydrate(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, patches will be validated to enforce remote event 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, and atoms in the `getters` table. This function can also be called multiple on the same client to subscribe to new signals.
```luau
Players.PlayerAdded:Connect(function(player)
server.addSignalsToClient(player, {
name = nameStore.getName,
surname = nameStore.getSurname,
})
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, {
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()
```
---
## Examples ## Examples
Check out this simple [example project →](https://github.com/littensy/charm-example) Check out this simple [example project →](https://github.com/littensy/charm-example)

View file

@ -5,6 +5,5 @@ end
local Charm = require(script.Parent.Parent.charm.src) local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T> export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm return Charm

View file

@ -6,13 +6,13 @@ local test = suite.test
local afterEach = suite.afterEach local afterEach = suite.afterEach
afterEach(function() afterEach(function()
client.reset() client.removeAllSignals()
end) end)
test("should hydrate atom", function() test("should hydrate atom", function()
local a = Charm.atom(1) local a = Charm.atom(1)
client.register({ a = a }) client.addSignals({ a = a })
client.hydrate({ client.hydrate({
{ type = "init", data = { a = 2 } }, { type = "init", data = { a = 2 } },
}) })
@ -24,7 +24,7 @@ test("should merge patch", function()
local a = Charm.atom({ b = 1, c = 2 }) local a = Charm.atom({ b = 1, c = 2 })
local previous = a() local previous = a()
client.register({ a = a }) client.addSignals({ a = a })
client.hydrate({ client.hydrate({
{ type = "init", data = { a = a() } }, { type = "init", data = { a = a() } },
}) })
@ -43,7 +43,7 @@ end)
test("should fix sparse array patches", function() test("should fix sparse array patches", function()
local a = Charm.atom({ 1 }) local a = Charm.atom({ 1 })
client.register({ a = a }) client.addSignals({ a = a })
client.hydrate({ client.hydrate({
{ type = "init", data = { a = a() } }, { type = "init", data = { a = a() } },
}) })
@ -63,7 +63,7 @@ test("should merge multiple patches", function()
local a = Charm.atom(1) local a = Charm.atom(1)
local calls = {} local calls = {}
client.register({ a = a }) client.addSignals({ a = a })
Charm.subscribe(a, function(value) Charm.subscribe(a, function(value)
table.insert(calls, value) table.insert(calls, value)
@ -87,7 +87,7 @@ test("should batch patches", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local states = {} local states = {}
client.register({ a = a, b = b }) client.addSignals({ a = a, b = b })
Charm.subscribe(a, function() Charm.subscribe(a, function()
states.b = b() states.b = b()

View file

@ -9,7 +9,7 @@ local test = suite.test
afterEach(function() afterEach(function()
server.reset() server.reset()
client.reset() client.removeAllSignals()
config.preserveHistory = false config.preserveHistory = false
end) end)
@ -20,8 +20,8 @@ test("should replicate server state to client", function()
local clientA = Charm.atom({ count = -1 }) local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 }) local clientB = Charm.atom({ count = -1 })
client.register({ a = clientA, b = clientB }) client.addSignals({ a = clientA, b = clientB })
server.subscribeClient(target, { a = serverA, b = serverB }) server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads) server.connect(function(syncTarget, payloads)
if syncTarget == target then if syncTarget == target then
client.hydrate(payloads) client.hydrate(payloads)
@ -54,8 +54,8 @@ test("should replicate historical server state to client", function()
config.preserveHistory = true config.preserveHistory = true
client.register({ a = clientA, b = clientB }) client.addSignals({ a = clientA, b = clientB })
server.subscribeClient(target, { a = serverA, b = serverB }) server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads) server.connect(function(syncTarget, payloads)
if syncTarget == target then if syncTarget == target then
client.hydrate(payloads) client.hydrate(payloads)

View file

@ -20,7 +20,7 @@ test("should replicate state changes", function()
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target, payloads
server.subscribeClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
@ -47,7 +47,7 @@ test("should replicate partial state patches", function()
local b = Charm.atom(0 :: number?) local b = Charm.atom(0 :: number?)
local target, payloads local target, payloads
server.subscribeClient(client, { a = a, b = b }) server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
@ -75,7 +75,7 @@ test("should init new clients", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA, payloadsB
server.subscribeClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads) server.connect(function(target, payloads)
if target == clientA then if target == clientA then
payloadsA = payloads payloadsA = payloads
@ -89,7 +89,7 @@ test("should init new clients", function()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") 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.a == a(), "Expected init payload to contain atom 'a' for client A")
server.subscribeClient(clientB, { a = a }) server.addSignalsToClient(clientB, { a = a })
a(2) a(2)
server.flush() server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
@ -104,13 +104,13 @@ test("should init new atoms", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local target, payloads local target, payloads
server.subscribeClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
server.flush() server.flush()
server.subscribeClient(client, { b = b }) server.addSignalsToClient(client, { b = b })
a(1) a(1)
b(1) b(1)
server.flush() server.flush()
@ -129,8 +129,8 @@ test("should only replicate to synced clients", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local payloadsA, payloadsB local payloadsA, payloadsB
server.subscribeClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.subscribeClient(clientB, { b = b }) server.addSignalsToClient(clientB, { b = b })
server.connect(function(target, payloads) server.connect(function(target, payloads)
if target == clientA then if target == clientA then
payloadsA = payloads payloadsA = payloads
@ -170,7 +170,7 @@ test("should validate state", function()
local client = {} local client = {}
local a = Charm.atom({}) local a = Charm.atom({})
server.subscribeClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function() end) server.connect(function() end)
a({ [0.5] = true }) a({ [0.5] = true })
@ -185,7 +185,7 @@ test("should serialize state", function()
local a = Charm.atom({ 1, 2, 3, 4, 5 }) local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads local payloads
server.subscribeClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)
payloads = p payloads = p
end) end)
@ -213,7 +213,7 @@ describe("historical", function()
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target, payloads
server.subscribeClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
target, payloads = ... target, payloads = ...
end) end)
@ -242,7 +242,7 @@ describe("historical", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA, payloadsB
server.subscribeClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads) server.connect(function(target, payloads)
if target == clientA then if target == clientA then
payloadsA = payloads payloadsA = payloads
@ -259,7 +259,7 @@ describe("historical", function()
assert(payloadsA[2].type == "patch", "Expected second payload to be patch 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") assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
server.subscribeClient(clientB, { a = a }) server.addSignalsToClient(clientB, { a = a })
a(3) a(3)
a(4) a(4)
server.flush() server.flush()
@ -278,7 +278,7 @@ describe("historical", function()
local b = Charm.atom(0) local b = Charm.atom(0)
local payloads local payloads
server.subscribeClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...) server.connect(function(_, ...)
payloads = ... payloads = ...
end) end)
@ -287,7 +287,7 @@ describe("historical", function()
assert(payloads[1].type == "init", "Expected first payload to be init") 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.a == 0, "Expected init payload to contain atom 'a'")
server.subscribeClient(client, { b = b }) server.addSignalsToClient(client, { b = b })
a(1) a(1)
a(2) a(2)
b(1) b(1)

View file

@ -2,17 +2,15 @@ local Charm = require("../../Charm")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local utils = require("../utils") local utils = require("../utils")
local atom = Charm.atom
local test = suite.test local test = suite.test
test("should flatten atoms", function() test("should flatten functions", function()
local getF, setF = Charm.signal(0)
local input = { local input = {
a = atom(0), a = Charm.atom(0),
b = { c = atom(0) }, b = { c = Charm.atom(0) },
d = { e = { f = utils.signalToAtom(getF, setF) } }, d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
} }
local result = utils.flattenState(input) local result = utils.flatten(input)
assert(result.a == input.a, "should flatten the first level") assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level") assert(result["b/c"] == input.b.c, "should flatten the second level")

View file

@ -2,60 +2,59 @@ local Charm = require("../Charm")
local patch = require("./patch") local patch = require("./patch")
local types = require("./types") local types = require("./types")
type Atom<T = any> = Charm.Atom<T>
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
local trackedAtoms: { [string]: Atom } = {} type Setter<T = any> = (update: ((T) -> T) | T) -> ()
--- Registers a map of atoms to the state with the corresponding keys on the local trackedSetters: { [string]: Setter } = {}
--- server. When the server sends updates, these atoms will be hydrated with
--- 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. --- the new values.
--- ---
--- If syncing signals instead of atoms, you can use `signalToAtom` to sync --- Atoms, signals converted to atoms, and signal setter functions are allowed.
--- signal getters and setters. --- Note that `server.addSignalsToClient` still requires some way to get the
--- state.
--- ---
--- @param atoms A map of atoms to sync with the server. --- @param setters A map of setter functions to sync with the server.
local function register(atoms: { [string]: Atom }) local function addSignals(setters: { [string]: Setter })
for key, atom in atoms do for key, setter in setters do
trackedAtoms[key] = atom trackedSetters[key] = setter
end end
end end
--- Unregisters from server state updates for the given keys. The atoms --- Unregisters from server state updates for the given keys. The signals
--- will retain their current values, but will no longer receive updates --- will retain their current values, but will no longer receive updates
--- from the server. --- from the server.
local function unregister(...: string) local function removeSignals(...: string)
for index = 1, select("#", ...) do for index = 1, select("#", ...) do
local key = select(index, ...) local key = select(index, ...)
trackedAtoms[key] = nil trackedSetters[key] = nil
end end
end end
--- Unsubscribes from all server state updates. --- Unsubscribes from all server state updates.
local function reset() local function removeAllSignals()
table.clear(trackedAtoms) table.clear(trackedSetters)
end end
local function applyInitPayload(data: { [string]: any }) local function applyInitPayload(data: { [string]: any })
for key, value in data do for key, value in data do
local atom = trackedAtoms[key] local setter = trackedSetters[key]
if atom then if setter then
atom(value) setter(value)
end end
end end
end end
local function applyPatchPayload(data: { [string | number]: any }) local function applyPatchPayload(data: { [string]: any })
local target = {} for key, value in data do
local setter = trackedSetters[key]
for key, atom in trackedAtoms do if setter then
target[key] = atom() setter(function(current)
end return patch.apply(current, value)
end)
target = patch.apply(target, data) end
for key, atom in trackedAtoms do
atom(target[key])
end end
end end
@ -74,8 +73,8 @@ local function hydrate(payloads: { SyncPayload })
end end
return { return {
register = register, addSignals = addSignals,
unregister = unregister, removeSignals = removeSignals,
reset = reset, removeAllSignals = removeAllSignals,
hydrate = hydrate, hydrate = hydrate,
} }

View file

@ -1,4 +1,4 @@
import { Atom } from "@rbxts/charm"; import { Atom, Getter, Setter } from "@rbxts/charm";
type Key = string | number | symbol; type Key = string | number | symbol;
@ -11,7 +11,8 @@ export interface None {
export type MaybeNone<T> = undefined extends T ? None : never; export type MaybeNone<T> = undefined extends T ? None : never;
type SelectorMap = Record<string, () => any>; type GetterMap = Record<string, Getter<any>>;
type SetterMap = Record<string, Setter<any>>;
/** /**
* Infers the type of the return values produced by a map of functions. * Infers the type of the return values produced by a map of functions.
@ -33,6 +34,7 @@ type DataType = DataTypes[keyof DataTypes];
* If a value was removed, it is replaced with `None`. This can be checked * If a value was removed, it is replaced with `None`. This can be checked
* using the `isNone` function. * using the `isNone` function.
*/ */
// TODO: Optimize type, this looks overly complex
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
| ReadonlyMap<infer K, infer V> | ReadonlyMap<infer K, infer V>
| Map<infer K, infer V> | Map<infer K, infer V>
@ -53,9 +55,9 @@ export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State
* A payload that can be sent from the server to the client to synchronize * A payload that can be sent from the server to the client to synchronize
* state between the two. * state between the two.
*/ */
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, FixArrays extends boolean = true> = export type SyncPayload<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Selectors> } | { type: "init"; data: StateOfMap<Getters> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, FixArrays> }; | { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
/** /**
* Global configuration options that affect the behavior of Charm Sync. * Global configuration options that affect the behavior of Charm Sync.
@ -102,28 +104,29 @@ export const config: {
*/ */
export namespace client { export namespace client {
/** /**
* Registers a map of atoms to the state with the corresponding keys on the * Registers a map of setters to the state with the corresponding keys on the
* server. When the server sends updates, these atoms will be hydrated with * server. When the server sends updates, these setters will be called with
* the new values. * the new values.
* *
* If syncing signals instead of atoms, you can use `signalToAtom` to sync * Atoms, signals converted to atoms, and signal setter functions are allowed.
* signal getters and setters as atoms. * Note that `server.addSignalsToClient` still requires some way to get the
* state.
* *
* @param atoms A map of atoms to sync with the server. * @param setters A map of setter functions to sync with the server.
*/ */
export function register(atoms: { [key: string]: Atom<any> }): void; export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void;
/** /**
* Unregisters from server state updates for the given keys. The atoms * Unregisters from server state updates for the given keys. The signals
* will retain their current values, but will no longer receive updates * will retain their current values, but will no longer receive updates
* from the server. * from the server.
*/ */
export function unregister(...keys: string[]): void; export function removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void;
/** /**
* Unsubscribes from all server state updates. * Unsubscribes from all server state updates.
*/ */
export function reset(): void; export function removeAllSignals(): void;
/** /**
* Merges the server's state patches into the client's state, either * Merges the server's state patches into the client's state, either
@ -131,8 +134,8 @@ export namespace client {
* *
* @param payloads The state updates received from the server. * @param payloads The state updates received from the server.
*/ */
export function hydrate<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>( export function hydrate<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
payloads: SyncPayload<Atoms, FixArrays>[], payloads: SyncPayload<Getters, FixArrays>[],
): void; ): void;
} }
@ -145,22 +148,21 @@ export namespace server {
* will receive a partial state patch to merge into their local state. May be * will receive a partial state patch to merge into their local state. May be
* called multiple times to subscribe to additional keys. * called multiple times to subscribe to additional keys.
* *
* Atoms, signals converted to atoms, and signal getter functions are allowed. * Atoms, computed signals, and signal getter functions are allowed. Note
* Note that the client still requires some way to set the state, so * that `client.addSignals` still requires some way to set the state.
* registering signals on the client should be done through `signalToAtom()`.
* *
* @param client The client receiving state updates. * @param client The client receiving state updates.
* @param signals A map of signals to sync with the client. * @param getters A map of getter functions to sync with the client.
*/ */
export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void; export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): void;
/** /**
* Unsubscribes a client from receiving all state updates. To only unsubscribe * Unsubscribes a client from receiving all state updates. To only unsubscribe
* from specific keys, use `unsubscribeClientFrom` instead. * from specific keys, use `removeSignalsFromClient` instead.
* *
* @param client The client receiving state updates. * @param client The client receiving state updates.
*/ */
export function unsubscribeClient(client: Player): void; export function removeClient(client: Player): void;
/** /**
* Unsubscribes a client from receiving updates for the given keys. * Unsubscribes a client from receiving updates for the given keys.
@ -168,7 +170,10 @@ export namespace server {
* @param client The client receiving state updates. * @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from. * @param keys The keys of the state to unsubscribe from.
*/ */
export function unsubscribeClientFrom(client: Player, ...keys: string[]): void; export function removeSignalsFromClient<Getters extends GetterMap = GetterMap>(
client: Player,
...keys: (keyof Getters)[]
): void;
/** /**
* Uses the callback to send state updates to clients that are subscribed * Uses the callback to send state updates to clients that are subscribed
@ -177,8 +182,8 @@ export namespace server {
* *
* @param onSync Called when sending patches to a client. * @param onSync Called when sending patches to a client.
*/ */
export function connect<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>( export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Atoms, FixArrays>[]) => void, onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void; ): void;
/** /**
@ -235,39 +240,24 @@ export namespace patch {
): State; ): State;
} }
interface NestedAtomMap { type DeepFlattenImpl<T, Prefix extends string = ""> = {
readonly [key: string]: NestedAtomMap | Atom<any>; [K in keyof T]: T[K] extends DataType | Callback
} ? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
: T[K] extends object
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
}[keyof T];
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
? Name
: Name extends string
? Path extends string
? `${Path}/${Name}`
: never
: never;
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
export type FlattenNestedAtoms<
Atoms extends NestedAtomMap,
Path extends Key | undefined = undefined,
> = IntersectValues<{
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
: Atoms[Name] extends NestedAtomMap
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
: never;
}>;
/** /**
* Flattens a nested atom map into a single object with slash-separated keys. * Flattens a nested table into a single table with slash-separated keys.
* Useful for recursively collecting atoms returned by modules. * Useful for recursively collecting atoms returned by modules.
* *
* @param atoms The nested atom map to flatten. * @param input A nested table.
* @returns A flattened atom map. * @return A flattened table with slash-separated keys.
*/ */
export function flattenAtoms<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>; export function flatten<T>(input: T): DeepFlatten<T>;
/** /**
* Checks whether a value is `None`. If `true`, the value is scheduled to be * Checks whether a value is `None`. If `true`, the value is scheduled to be

View file

@ -14,6 +14,6 @@ return {
server = server, server = server,
patch = patch, patch = patch,
isNone = patch.isNone, isNone = patch.isNone,
flattenState = utils.flattenState, flatten = utils.flatten,
signalToAtom = utils.signalToAtom, signalToAtom = utils.signalToAtom,
} }

View file

@ -8,12 +8,9 @@ type SyncPayload = types.SyncPayload
local globals = Charm.globals local globals = Charm.globals
type SyncStatus = number type SyncStatus = number
type ClientKeys = { [string]: SyncStatus }
type ClientKeys = { type GetterState<T = any> = {
[string]: SyncStatus,
}
type TrackedState<T = any> = {
getter: () -> T, getter: () -> T,
currentValue: T, currentValue: T,
previousValue: T, previousValue: T,
@ -29,7 +26,7 @@ type StateUpdate = {
local PENDING_INITIAL_STATE: SyncStatus = 0 local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1 local LISTENING_FOR_CHANGES: SyncStatus = 1
local trackedSignals: { [string]: TrackedState } = {} local trackedGetters: { [string]: GetterState } = {}
local globalUpdates: { StateUpdate } = {} local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {} local clients: { [any]: ClientKeys } = {}
@ -37,9 +34,9 @@ local connection: RBXScriptConnection?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
local function startTracking<T>(key: string, getter: () -> T) local function startTracking<T>(key: string, getter: () -> T)
if trackedSignals[key] then if trackedGetters[key] then
-- Ignore duplicates, but warn against name collisions -- Ignore duplicates, but warn against name collisions
if trackedSignals[key].getter ~= getter then if trackedGetters[key].getter ~= getter then
warn("[Charm] Attempted to sync different states with the same key:", key) warn("[Charm] Attempted to sync different states with the same key:", key)
end end
return return
@ -68,14 +65,14 @@ local function startTracking<T>(key: string, getter: () -> T)
trackedState.currentValue = newValue trackedState.currentValue = newValue
end) end)
trackedSignals[key] = trackedState trackedGetters[key] = trackedState
end end
local function stopTracking(key: string) local function stopTracking(key: string)
local tracker = trackedSignals[key] local tracker = trackedGetters[key]
if tracker then if tracker then
tracker.dispose() tracker.dispose()
trackedSignals[key] = nil trackedGetters[key] = nil
end end
end end
@ -88,7 +85,7 @@ local function purgeUntracked()
end end
end end
for key in trackedSignals do for key in trackedGetters do
if not active[key] then if not active[key] then
stopTracking(key) stopTracking(key)
end end
@ -106,11 +103,11 @@ local function diffGlobalUpdateBuffer(): { any }
return patches return patches
end end
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
local clientKeys = clients[client] local clientKeys = clients[client]
if not clientKeys then if not clientKeys then
return {} return nil
end end
local payloads: { SyncPayload } = {} local payloads: { SyncPayload } = {}
@ -146,10 +143,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local initData: { [string]: any } = {} local initData: { [string]: any } = {}
for key, status in clientKeys do for key, status in clientKeys do
local trackedState = trackedSignals[key] local getterState = trackedGetters[key]
if status == PENDING_INITIAL_STATE and trackedState then if status == PENDING_INITIAL_STATE and getterState then
initData[key] = trackedState.currentValue initData[key] = getterState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
@ -165,7 +162,7 @@ local function diffGlobalState(): any
local currentGlobalState: { [string]: any } = {} local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {}
for key, tracker in trackedSignals do for key, tracker in trackedGetters do
currentGlobalState[key] = tracker.currentValue currentGlobalState[key] = tracker.currentValue
previousGlobalState[key] = tracker.previousValue previousGlobalState[key] = tracker.previousValue
tracker.previousValue = tracker.currentValue tracker.previousValue = tracker.currentValue
@ -174,11 +171,11 @@ local function diffGlobalState(): any
return patch.diff(previousGlobalState, currentGlobalState) return patch.diff(previousGlobalState, currentGlobalState)
end end
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
local clientKeys = clients[client] local clientKeys = clients[client]
if not clientKeys then if not clientKeys then
return {} return nil
end end
local payloads: { SyncPayload } = {} local payloads: { SyncPayload } = {}
@ -188,12 +185,12 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
-- Create patches for initialized signals that changed, and add signals -- Create patches for initialized signals that changed, and add signals
-- that are still pending initial state to the init payload. -- that are still pending initial state to the init payload.
for key, status in clientKeys do for key, status in clientKeys do
local trackedState = trackedSignals[key] local getterState = trackedGetters[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
patchData[key] = globalPatch[key] patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue initData[key] = getterState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
@ -214,23 +211,22 @@ end
--- called multiple times to subscribe to additional keys. --- called multiple times to subscribe to additional keys.
--- ---
--- Atoms, signals converted to atoms, and signal getter functions are allowed. --- Atoms, signals converted to atoms, and signal getter functions are allowed.
--- Note that the client still requires some way to set the state, so --- Note that `client.addSignals` still requires some way to set the state.
--- registering signals on the client should be done through `signalToAtom()`.
--- ---
--- @param client The client receiving state updates. --- @param client The client receiving state updates.
--- @param signals A map of signals to sync with the client. --- @param getters A map of getter functions to sync with the client.
local function subscribeClient(client: any, signals: { [string]: () -> any }) local function addSignalsToClient(client: any, getters: { [string]: () -> any })
if not clients[client] then if not clients[client] then
clients[client] = {} clients[client] = {}
end end
local clientKeys = clients[client] local clientKeys = clients[client]
for key, state in signals do for key, getter in getters do
startTracking(key, if type(state) == "function" then state else state.get) startTracking(key, if type(getter) == "function" then getter else getter.get)
if not clientKeys[key] then if not clientKeys[key] then
-- Mark the signal as pending initial state so we send its full -- Mark the getter as pending initial state so we send its full
-- value the next time we sync. -- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE clientKeys[key] = PENDING_INITIAL_STATE
end end
@ -241,7 +237,7 @@ end
--- from specific keys, use `unsubscribeClientFrom` instead. --- from specific keys, use `unsubscribeClientFrom` instead.
--- ---
--- @param client The client receiving state updates. --- @param client The client receiving state updates.
local function unsubscribeClient(client: any) local function removeClient(client: any)
clients[client] = nil clients[client] = nil
end end
@ -249,7 +245,7 @@ end
--- ---
--- @param client The client receiving state updates. --- @param client The client receiving state updates.
--- @param keys The keys of the state to unsubscribe from. --- @param keys The keys of the state to unsubscribe from.
local function unsubscribeClientFrom(client: any, ...: string) local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client] local clientKeys = clients[client]
if not clientKeys then if not clientKeys then
@ -262,7 +258,7 @@ local function unsubscribeClientFrom(client: any, ...: string)
end end
if next(clientKeys) == nil then if next(clientKeys) == nil then
clients[client] = nil removeClient(client)
end end
end end
@ -274,7 +270,8 @@ local function flush()
return return
end end
local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState() local granular = config.preserveHistory
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
-- While payload objects are technically mutable as they are unique to -- While payload objects are technically mutable as they are unique to
-- each client, the data inside them is not safe to mutate. -- each client, the data inside them is not safe to mutate.
@ -283,11 +280,11 @@ local function flush()
end end
for client in clients do for client in clients do
local payloads: { SyncPayload } = if config.preserveHistory local payloads = if granular
then selectFromGlobalPatchList(client, globalPatch) then selectFromGlobalPatchList(client, globalPatch)
else selectFromGlobalPatch(client, globalPatch) else selectFromGlobalPatch(client, globalPatch)
if payloads[1] then if payloads and payloads[1] then
if task then if task then
task.spawn(syncCallback, client, payloads) task.spawn(syncCallback, client, payloads)
else else
@ -346,16 +343,16 @@ end
--- Unsubscribes all clients, stops tracking all state, and disconnects --- Unsubscribes all clients, stops tracking all state, and disconnects
--- from the automatic sync interval. --- from the automatic sync interval.
local function reset() local function reset()
table.clear(trackedSignals) table.clear(trackedGetters)
table.clear(clients) table.clear(clients)
table.clear(globalUpdates) table.clear(globalUpdates)
disconnect() disconnect()
end end
return { return {
subscribeClient = subscribeClient, addSignalsToClient = addSignalsToClient,
unsubscribeClient = unsubscribeClient, removeClient = removeClient,
unsubscribeClientFrom = unsubscribeClientFrom, removeSignalsFromClient = removeSignalsFromClient,
connect = connect, connect = connect,
disconnect = disconnect, disconnect = disconnect,
flush = flush, flush = flush,

View file

@ -2,40 +2,35 @@ local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T> type Atom<T> = Charm.Atom<T>
type NestedState = { type NestedTable = {
[string]: NestedState | () -> any, [any]: NestedTable | any,
} }
type FlattenedState = { type FlattenedTable = {
[string]: () -> any, [any]: any,
} }
local function isAtomOrSignal(value: any): boolean --- Flattens a nested table into a single table with slash-separated keys.
return type(value) ~= "table" or (type(value.get) == "function" and type(value.set) == "function") --- Useful for recursively collecting atoms returned by modules.
end
--- Flattens a nested map of atoms or signals into a single object with
--- slash-separated keys. Useful for recursively collecting atoms returned by
--- modules.
--- ---
--- @param states A nested table of atoms or signals. --- @param input A nested table.
--- @return A flattened map of atoms or signals. --- @return A flattened table with slash-separated keys.
local function flattenState(states: NestedState): FlattenedState local function flatten(input: NestedTable): FlattenedTable
local result: FlattenedState = {} local result: FlattenedTable = {}
local function visit(node: NestedState, path: string) local function visit(node: NestedTable, path: string)
for key, value in node do for key, value in node do
local location = if path == "" then key else path .. "/" .. key local location = if path == "" then key else path .. "/" .. key
if isAtomOrSignal(value) then if type(value) == "table" then
result[location] = value :: () -> any visit(value :: NestedTable, location)
else else
visit(value :: NestedState, location) result[location] = value
end end
end end
end end
visit(states, "") visit(input, "")
return result return result
end end
@ -54,6 +49,6 @@ local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T)
end end
return { return {
flattenState = flattenState, flatten = flatten,
signalToAtom = signalToAtom, signalToAtom = signalToAtom,
} }

View file

@ -2,17 +2,17 @@ type Key = string | number | symbol;
type Cleanup = () => void; type Cleanup = () => void;
/** /**
* A reactive atom that acts as both a getter and setter. Calling the atom with * A reactive signal that acts as both a getter and setter. Calling the signal
* an argument sets its value, while calling it with no arguments returns its * with an argument sets its value, while calling it with no arguments returns
* current value. * its current value.
*/ */
export interface Atom<T> { export interface Atom<T> {
(newValue: T | ((currentValue: T) => T)): T; (newValue: T | ((currentValue: T) => T)): T;
(): T; (): T;
} }
export type Selector<T> = () => T; export type Getter<T> = () => T;
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
export type Equals<T> = (current: T, incoming: T) => boolean; export type Equals<T> = (current: T, incoming: T) => boolean;
/** /**

View file

@ -6,13 +6,11 @@ local utils = require("@self/utils")
type Update<T> = ((previous: T) -> T) | T type Update<T> = ((previous: T) -> T) | T
--- A reactive atom that acts as both a getter and setter. Calling the atom with --- A reactive signal that acts as both a getter and setter. Calling the signal
--- an argument sets its value, while calling it with no arguments returns its --- with an argument sets its value, while calling it with no arguments returns
--- current value. --- its current value.
export type Atom<T = any> = (...Update<T>) -> T export type Atom<T = any> = (...Update<T>) -> T
export type Selector<T = any> = () -> T
return { return {
core = core, core = core,
globals = globals, globals = globals,

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.charm.src) local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T> export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm return Charm

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.charm.src) local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T> export type Atom<T = any> = Charm.Atom<T>
export type Selector<T = any> = Charm.Selector<T>
return Charm return Charm