mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: finalize sync docs
This commit is contained in:
parent
37f79ecc3f
commit
ea40833d3b
15 changed files with 400 additions and 206 deletions
248
README.md
248
README.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
packages/charm-sync/src/index.d.ts
vendored
96
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
packages/charm/src/index.d.ts
vendored
10
packages/charm/src/index.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue