mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
Compare commits
121 commits
main
...
charm-v0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5753ea71a | ||
|
|
9f7047d484 | ||
|
|
fd6b3efbb5 | ||
|
|
5c27052965 | ||
|
|
50afcaf6e9 | ||
|
|
00880bec55 | ||
|
|
3460618ee0 | ||
|
|
89478f679c | ||
|
|
d741640d28 | ||
|
|
78dbaa476c | ||
|
|
612612b130 | ||
|
|
10521d05c2 | ||
|
|
0d1e85754a | ||
|
|
69d47d580c | ||
|
|
d58d6ffd0b | ||
|
|
6cfdd46a81 | ||
|
|
5aee237f11 | ||
|
|
787084ba59 | ||
|
|
fe7064acee | ||
|
|
fe57482aaf | ||
|
|
5157975091 | ||
|
|
91ef244ef3 | ||
|
|
1ac071b62a | ||
|
|
62fc690a7d | ||
|
|
41d59cdfad | ||
|
|
28872264a2 | ||
|
|
a9658a8aa2 | ||
|
|
a44355b3b6 | ||
|
|
e08204a9d1 | ||
|
|
15b90c5986 | ||
|
|
a20ee3698a | ||
|
|
72b290219d | ||
|
|
fd752d87d7 | ||
|
|
c1b417ec2b | ||
|
|
73954f56e7 | ||
|
|
9d4af8e669 | ||
|
|
b826401bf3 | ||
|
|
b3a76face9 | ||
|
|
aebe3d27ee | ||
|
|
391125b3a2 | ||
|
|
422ee43a94 | ||
|
|
189ae0f168 | ||
|
|
9b89284529 | ||
|
|
1206e008a7 | ||
|
|
366fc668a2 | ||
|
|
9f66803f04 | ||
|
|
f082956ed9 | ||
|
|
b733369e70 | ||
|
|
5c90f928f7 | ||
|
|
bd23d54374 | ||
|
|
7bad215560 | ||
|
|
e583574751 | ||
|
|
ed9566bfff | ||
|
|
29d8add265 | ||
|
|
898fbdba29 | ||
|
|
fdc598f6e6 | ||
|
|
d890f4fc2c | ||
|
|
96ab2317f2 | ||
|
|
dffeb35933 | ||
|
|
7bb6edb403 | ||
|
|
2aa7bd3e15 | ||
|
|
86ade3c36d | ||
|
|
3d4ce41d20 | ||
|
|
90399a816f | ||
|
|
682b3dfcff | ||
|
|
0f2f2d642c | ||
|
|
ea40833d3b | ||
|
|
37f79ecc3f | ||
|
|
7a88c784fa | ||
|
|
32788b19e1 | ||
|
|
1e0b10aef6 | ||
|
|
bf92413a97 | ||
|
|
7a63993293 | ||
|
|
027c5bf8f8 | ||
|
|
87e7260c16 | ||
|
|
1657e2cb74 | ||
|
|
f5709c3112 | ||
|
|
5a05f01223 | ||
|
|
0dbc0587a0 | ||
|
|
079abaacc1 | ||
|
|
4a07297a13 | ||
|
|
cb52ece4d3 | ||
|
|
a8051257e5 | ||
|
|
77c98ca94a | ||
|
|
708bc55de7 | ||
|
|
d2979ebd4c | ||
|
|
95476e93ec | ||
|
|
a9c60c701f | ||
|
|
581488cb4f | ||
|
|
61d9f424aa | ||
|
|
4edc952c05 | ||
|
|
4931515a95 | ||
|
|
25728e589f | ||
|
|
72f936591e | ||
|
|
210e948225 | ||
|
|
546df8cabe | ||
|
|
4e5f656158 | ||
|
|
a53683d59e | ||
|
|
571d2c0d91 | ||
|
|
dcb1de2844 | ||
|
|
3f1556a6e2 | ||
|
|
554010f1bd | ||
|
|
b61d0b3b86 | ||
|
|
f704acce71 | ||
|
|
ada1b80b87 | ||
|
|
d56672384a | ||
|
|
bcf02a615f | ||
|
|
6bfd2f3d21 | ||
|
|
939a812b43 | ||
|
|
1d5d3aed8c | ||
|
|
e276863b2d | ||
|
|
7c48bd2fa4 | ||
|
|
0b37f9cd54 | ||
|
|
d33c4ce891 | ||
|
|
8d385356cd | ||
|
|
3eba56e298 | ||
|
|
8df67d6fff | ||
|
|
209f9b583b | ||
|
|
a0fc88ede1 | ||
|
|
e34b0c9aca | ||
|
|
861ee5acd7 |
58 changed files with 888 additions and 1936 deletions
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
|
@ -20,13 +20,8 @@ jobs:
|
|||
- name: Check code quality
|
||||
run: lute run check
|
||||
|
||||
- name: Run ESLint and Prettier
|
||||
run: |
|
||||
pnpm eslint packages
|
||||
pnpm prettier --check packages
|
||||
|
||||
- name: Run tests
|
||||
run: lute run test
|
||||
run: lute run tests
|
||||
|
||||
- name: Build models
|
||||
run: lute run build
|
||||
|
|
|
|||
7
.luaurc
7
.luaurc
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"languageMode": "strict",
|
||||
"aliases": {
|
||||
"lute": "~/.lute/typedefs/1.0.0/lute",
|
||||
"std": "~/.lute/typedefs/1.0.0/std"
|
||||
"lute": "~/.lute/typedefs/0.1.0/lute",
|
||||
"std": "~/.lute/typedefs/0.1.0/std"
|
||||
},
|
||||
"lint": {
|
||||
"LocalShadow": false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
local fs = require("@std/fs")
|
||||
local process = require("@lute/process")
|
||||
|
||||
fs.createDirectory("build")
|
||||
fs.createdirectory("build", { makeparents = true })
|
||||
|
||||
process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" })
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
local fs = require("@std/fs")
|
||||
local net = require("@std/net")
|
||||
local net = require("@lute/net")
|
||||
local process = require("@lute/process")
|
||||
|
||||
process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })
|
||||
|
||||
fs.writeStringToFile(
|
||||
fs.writestringtofile(
|
||||
"roblox.d.luau",
|
||||
net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
|
||||
)
|
||||
|
|
@ -16,7 +16,6 @@ local analyze = process.run({
|
|||
"--ignore=**/node_modules/**",
|
||||
"--base-luaurc=.luaurc",
|
||||
"--definitions=roblox.d.luau",
|
||||
"--flag:LuauSolverV2=true",
|
||||
"packages",
|
||||
"tests",
|
||||
"benches",
|
||||
|
|
@ -24,10 +23,12 @@ local analyze = process.run({
|
|||
|
||||
fs.remove("roblox.d.luau")
|
||||
|
||||
local selene = process.run({ "selene", "packages", "tests", "benches" }, { stdio = "inherit" })
|
||||
|
||||
local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" })
|
||||
|
||||
local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" })
|
||||
|
||||
if not (analyze.ok and stylua.ok and eslint.ok) then
|
||||
if not (analyze.ok and selene.ok and stylua.ok and eslint.ok) then
|
||||
process.exit(1)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
(require)("../tests/run.server")
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
public-hoist-pattern[]=*@rbxts*
|
||||
197
README.md
197
README.md
|
|
@ -18,20 +18,20 @@
|
|||
|
||||
</div>
|
||||
|
||||
Charm is a reactive state management library designed for storing data in discrete containers called _signals_. Connect behavior to data with signals, ensuring systems stay up-to-date with their underlying data while reactivity eliminates the need for manual updates.
|
||||
Charm is a state management library based on [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Connect behavior to data with reactive signals, ensuring games stay up-to-date with their underlying data while eliminating the need for manual updates.
|
||||
|
||||
**Build game state from simple building blocks:**
|
||||
|
||||
- Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value
|
||||
- React to state changes with [effects](#effectcallback): re-run code when signals update
|
||||
- Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals
|
||||
- Store state in [signals](#signalinitialvalue-equals): state containers that hold a value
|
||||
- React to state changes with [effects](#effectcallback): re-run code when a dependency updates
|
||||
- Derive values from state with [computed signals](#computedgetter): memoized functions with dependency tracking
|
||||
|
||||
**Want to learn more about reactivity?**
|
||||
**Want to learn more about signals?**
|
||||
|
||||
- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf
|
||||
- https://preactjs.com/blog/introducing-signals
|
||||
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
|
||||
- https://github.com/roblox/signals
|
||||
- https://angular.dev/guide/signals
|
||||
|
||||
[Migrating from an older version of Charm?](#migration)
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Charm is a reactive state management library designed for storing data in discre
|
|||
- [`subscribe(getter, callback)`](#subscribegetter-callback)
|
||||
- [`untracked(callback)`](#untrackedcallback)
|
||||
- [`batch(callback)`](#batchcallback)
|
||||
- [`mapped(getter, transform)`](#mappedgetter-transform)
|
||||
- [`mapped(getter, mapper)`](#mappedgetter-mapper)
|
||||
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
|
||||
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
|
||||
- [`trigger(callback)`](#triggercallback)
|
||||
|
|
@ -472,11 +472,11 @@ end)
|
|||
|
||||
---
|
||||
|
||||
### `mapped(getter, transform)`
|
||||
### `mapped(getter, mapper)`
|
||||
|
||||
The `mapped` function iterates over every key in a table and uses the transform function to assign them to a new key and value. The result is returned as a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, `transform` is called for that key and its current value.
|
||||
The `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 transform function is used as the new value:
|
||||
The first value returned by the mapper is used as the new value:
|
||||
|
||||
```luau
|
||||
local getList, setList = signal({ "a", "b", "c" })
|
||||
|
|
@ -488,7 +488,7 @@ end)
|
|||
print(getUppercase()) -- { "A", "B", "C" }
|
||||
```
|
||||
|
||||
If the transform function returns two values, the second value is used as the new key:
|
||||
If the mapper returns two values, the second value is used as the new key:
|
||||
|
||||
```luau
|
||||
local getList, setList = signal({ "a", "b", "c" })
|
||||
|
|
@ -602,7 +602,7 @@ Charm exposes the following global flags to customize behavior:
|
|||
| frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. |
|
||||
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
|
||||
|
||||
The `strict` and `frozen` flags are automatically enabled in Roblox Studio.
|
||||
The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -623,99 +623,72 @@ CharmSync = "littensy/charm-sync@VERSION"
|
|||
|
||||
### Quick Start
|
||||
|
||||
Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory:
|
||||
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
|
||||
-- store.luau
|
||||
|
||||
local getStatus, setStatus = signal("intermission")
|
||||
local timerAtom = atom(20)
|
||||
local getInventories, setInventories = signal({} :: { [Username]: Inventory })
|
||||
-- nameStore
|
||||
local getName, setName = signal("John")
|
||||
local getSurname, setSurname = signal("Doe")
|
||||
local ageAtom = atom(20)
|
||||
|
||||
return {
|
||||
getStatus = getStatus,
|
||||
setStatus = setStatus,
|
||||
timerAtom = timerAtom,
|
||||
getInventories = getInventories,
|
||||
setInventories = setInventories,
|
||||
getName = getName,
|
||||
setName = setName,
|
||||
getSurname = getSurname,
|
||||
setSurname = setSurname,
|
||||
ageAtom = ageAtom,
|
||||
}
|
||||
```
|
||||
|
||||
#### Server setup
|
||||
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.
|
||||
|
||||
When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
|
||||
|
||||
Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to:
|
||||
Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to.
|
||||
|
||||
```luau
|
||||
-- sync.server.luau
|
||||
|
||||
playerReadyEvent.OnServerEvent:Connect(function(player)
|
||||
-- Sync signal getters, computed signals, atoms, or reactive proxies
|
||||
local function onPlayerAdded(player: Player)
|
||||
-- Add signal getters, computed signals, atoms, or reactive objects
|
||||
server.addSignalsToClient(player, {
|
||||
status = store.getStatus,
|
||||
timer = store.timerAtom,
|
||||
-- Signals unique to a player should also have a key unique to
|
||||
-- that player, to avoid overwriting other synced signals
|
||||
[`inventory-{player.UserId}`] = computed(function()
|
||||
return store.getInventories()[player.Name]
|
||||
end),
|
||||
name = nameStore.getName,
|
||||
surname = nameStore.getSurname,
|
||||
age = nameStore.ageAtom,
|
||||
})
|
||||
end)
|
||||
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 replicate state updates to clients
|
||||
-- Customize how you send state updates to clients
|
||||
syncEvent:FireClient(player, updates)
|
||||
end)
|
||||
|
||||
-- Changes now get replicated to clients
|
||||
store.setStatus("started")
|
||||
store.timerAtom(30)
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player.
|
||||
>
|
||||
> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning.
|
||||
|
||||
#### Client setup
|
||||
> [!NOTE]
|
||||
> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning.
|
||||
|
||||
To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts.
|
||||
|
||||
After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
|
||||
|
||||
```luau
|
||||
-- sync.client.luau
|
||||
|
||||
-- The server is replicating a signal containing the client's
|
||||
-- inventory, so this signal will receive those updates
|
||||
local getInventory, setInventory = signal({} :: Inventory)
|
||||
|
||||
-- Add signal setters, atoms, or reactive proxies
|
||||
client.addSignals({
|
||||
status = store.setStatus,
|
||||
timer = store.timerAtom,
|
||||
-- Signals unique to a player should also have a key unique to
|
||||
-- that player, to avoid name collisions
|
||||
[`inventory-{Players.LocalPlayer.UserId}`] = setInventory,
|
||||
name = nameStore.setName,
|
||||
surname = nameStore.setSurname,
|
||||
age = nameStore.ageAtom,
|
||||
})
|
||||
|
||||
-- Update client signals when receiving updates from the server
|
||||
syncEvent.OnClientEvent:Connect(function(updates)
|
||||
client.patch(updates)
|
||||
end)
|
||||
|
||||
-- Notify the server that the client is ready to receive updates
|
||||
playerReadyEvent:FireServer()
|
||||
|
||||
-- The signals will now update to match the server's state
|
||||
subscribe(getInventory, function(inventory)
|
||||
print("My inventory is", table.concat(inventory))
|
||||
end)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -728,49 +701,37 @@ A configuration table that customizes the behavior of Charm Sync on the server.
|
|||
| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. |
|
||||
| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. |
|
||||
| fixArrays | `true` | When `true`, Charm will attempt to work around remote event limitations that cause data loss in array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
|
||||
| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will emit a warning. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). |
|
||||
| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
|
||||
| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). |
|
||||
|
||||
---
|
||||
|
||||
### `server.addSignalsToClient(client, getters)`
|
||||
|
||||
The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed.
|
||||
The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed.
|
||||
|
||||
You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals.
|
||||
You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals.
|
||||
|
||||
```luau
|
||||
playerReadyEvent.OnServerEvent:Connect(function(player)
|
||||
Players.PlayerAdded:Connect(function(player)
|
||||
server.addSignalsToClient(player, {
|
||||
status = store.getStatus,
|
||||
timer = store.timerAtom,
|
||||
name = nameStore.getName,
|
||||
surname = nameStore.getSurname,
|
||||
age = nameStore.ageAtom,
|
||||
})
|
||||
end)
|
||||
```
|
||||
|
||||
You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal:
|
||||
You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player:
|
||||
|
||||
```luau
|
||||
playerReadyEvent.OnServerEvent:Connect(function(player)
|
||||
server.addSignalsToClient(player, {
|
||||
-- 🟢 Good: Player-specific signals are synced with a unique key
|
||||
[`playerData-{player.UserId}`] = computed(function()
|
||||
return clientStore.getPlayerData(player.UserId)
|
||||
end),
|
||||
|
||||
-- 🔴 Bad: Syncing different signals with the same keys does not work
|
||||
playerData = computed(function()
|
||||
return clientStore.getPlayerData(player.UserId)
|
||||
[`data-{player.UserId}`] = computed(function()
|
||||
return getPlayerData(player.UserId)
|
||||
end),
|
||||
})
|
||||
end)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player.
|
||||
>
|
||||
> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning.
|
||||
|
||||
---
|
||||
|
||||
### `server.removeSignalsFromClient(client, ...keys)`
|
||||
|
|
@ -779,11 +740,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke
|
|||
|
||||
```luau
|
||||
server.addSignalsToClient(player, {
|
||||
status = store.getStatus,
|
||||
timer = store.timerAtom,
|
||||
name = nameStore.getName,
|
||||
surname = nameStore.getSurname,
|
||||
})
|
||||
|
||||
server.removeSignalsFromClient(player, "status")
|
||||
server.removeSignalsFromClient(player, "surname")
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -793,7 +754,7 @@ server.removeSignalsFromClient(player, "status")
|
|||
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
|
||||
playerReadyEvent.OnServerEvent:Connect(function(player)
|
||||
Players.PlayerAdded:Connect(function(player)
|
||||
server.addSignalsToClient(player, signals)
|
||||
end)
|
||||
|
||||
|
|
@ -844,11 +805,9 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit
|
|||
|
||||
```luau
|
||||
client.addSignals({
|
||||
status = store.setStatus,
|
||||
timer = store.timerAtom,
|
||||
-- Signals unique to a player should also have a key unique to
|
||||
-- that player, to avoid name collisions
|
||||
[`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
|
||||
name = nameStore.setName,
|
||||
surname = nameStore.setSurname,
|
||||
age = nameStore.ageAtom,
|
||||
})
|
||||
```
|
||||
|
||||
|
|
@ -860,11 +819,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta
|
|||
|
||||
```luau
|
||||
client.addSignals({
|
||||
status = store.setStatus,
|
||||
timer = store.timerAtom,
|
||||
name = nameStore.setName,
|
||||
surname = nameStore.setSurname,
|
||||
})
|
||||
|
||||
client.removeSignals("status")
|
||||
client.removeSignals("name")
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -877,9 +836,8 @@ You should call `patch` when receiving updates from the server from a remote eve
|
|||
|
||||
```luau
|
||||
client.addSignals({
|
||||
status = store.setStatus,
|
||||
timer = store.timerAtom,
|
||||
[`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
|
||||
name = nameStore.setName,
|
||||
surname = nameStore.setSurname,
|
||||
})
|
||||
|
||||
syncEvent.OnClientEvent:Connect(function(updates)
|
||||
|
|
@ -895,8 +853,8 @@ If you have a lot of signals to sync between the server and clients, it might be
|
|||
|
||||
```luau
|
||||
local sharedState = {
|
||||
status = signalToAtom(store.getStatus, store.setStatus),
|
||||
inventories = signalToAtom(store.getInventories, store.setInventories),
|
||||
name = signalToAtom(nameStore.getName, nameStore.setName),
|
||||
surname = signalToAtom(nameStore.getSurname, nameStore.setSurname),
|
||||
}
|
||||
|
||||
client.addSignals(sharedState)
|
||||
|
|
@ -909,7 +867,7 @@ server.addSignalsToClient(client, sharedState)
|
|||
|
||||
Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`.
|
||||
|
||||
But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`.
|
||||
But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`.
|
||||
|
||||
This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload.
|
||||
|
||||
|
|
@ -925,9 +883,6 @@ You can opt-in to deep reactivity with the Deep Charm library:
|
|||
|
||||
### Installation
|
||||
|
||||
> [!WARNING]
|
||||
> Deep Charm is an experimental library that is not fully documented, and the API is subject to breaking changes.
|
||||
|
||||
```sh
|
||||
npm install @rbxts/deep-charm
|
||||
yarn add @rbxts/deep-charm
|
||||
|
|
@ -983,22 +938,6 @@ local proxy = reactive(raw)
|
|||
print(toRaw(proxy) == raw) -- true
|
||||
```
|
||||
|
||||
Calling `toRaw()` on a proxy also subscribes to its changes. This allows you to use proxies with `observe()` and other Charm APIs that accept a getter function:
|
||||
|
||||
```luau
|
||||
local proxy, updateProxy = reactive({})
|
||||
|
||||
observe(function()
|
||||
return toRaw(proxy)
|
||||
end, function(value, key)
|
||||
print(`Value {value} added at key {key}`)
|
||||
end)
|
||||
|
||||
updateProxy(function(state)
|
||||
table.insert(state, "foo")
|
||||
end) -- Value foo added at key 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `isReactive(value)`
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ local function run(name: string, test: () -> ())
|
|||
|
||||
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||
|
||||
for name, duration in times do
|
||||
table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`)
|
||||
for name, elapsed in times do
|
||||
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||
end
|
||||
|
||||
print(table.concat(results, " | "))
|
||||
|
|
|
|||
|
|
@ -3,56 +3,55 @@ local bench = require("./bench")
|
|||
|
||||
local computed = Charm.computed
|
||||
local mapped = Charm.mapped
|
||||
local setActiveSub = Charm.setActiveSub
|
||||
local signal = Charm.signal
|
||||
|
||||
type Key = number
|
||||
type Value = number
|
||||
type Entry = { number }
|
||||
|
||||
local count = 512
|
||||
local initialState: { [Key]: Value } = table.create(count, 0)
|
||||
local initialState: { Entry } = table.create(count)
|
||||
|
||||
local function expensiveOperation()
|
||||
for _ = 1, 100 do
|
||||
continue
|
||||
end
|
||||
for i = 1, count do
|
||||
initialState[i] = table.create(64, i)
|
||||
end
|
||||
|
||||
local function simpleMapped(
|
||||
source: () -> { [Key]: Value },
|
||||
mapper: (Value, Key) -> (Value, Key?)
|
||||
): () -> { [Key]: Value }
|
||||
local function mapper(value: Entry, key: number): (Entry, number)
|
||||
return table.clone(value), key + 1
|
||||
end
|
||||
|
||||
local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
|
||||
return computed(function()
|
||||
local state = source()
|
||||
local nextState = {}
|
||||
|
||||
for keyIn, valueIn in state do
|
||||
local valueOut, keyOut = mapper(valueIn, keyIn)
|
||||
nextState[if keyOut == nil then keyIn else keyOut] = valueOut
|
||||
for index, value in state do
|
||||
local currentSub = setActiveSub(nil)
|
||||
local success, valueOut, keyOut = pcall(mapper, value, index)
|
||||
setActiveSub(currentSub)
|
||||
|
||||
if success then
|
||||
nextState[keyOut] = valueOut
|
||||
end
|
||||
end
|
||||
|
||||
return nextState
|
||||
return state
|
||||
end)
|
||||
end
|
||||
|
||||
local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
|
||||
local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
|
||||
return function(state)
|
||||
local nextState = table.clone(state)
|
||||
for key in state do
|
||||
if math.random() < updateChance then
|
||||
nextState[key] += 1
|
||||
for index, value in state do
|
||||
if math.random() < percent then
|
||||
nextState[index] = table.clone(value)
|
||||
end
|
||||
end
|
||||
return nextState
|
||||
end
|
||||
end
|
||||
|
||||
for _, updateChance in { 0.1, 0.5, 1 } do
|
||||
local function mapper(value: Value, key: Key): (Value, Key?)
|
||||
expensiveOperation()
|
||||
return value + 1, key + 1
|
||||
end
|
||||
|
||||
bench.run(`mapped {updateChance}`, function()
|
||||
for _, percent in { 0.1, 0.5, 1 } do
|
||||
bench.run(`mapped {percent}`, function()
|
||||
local src, setSrc = signal(initialState)
|
||||
local out = mapped(src, mapper)
|
||||
|
||||
|
|
@ -60,14 +59,14 @@ for _, updateChance in { 0.1, 0.5, 1 } do
|
|||
out()
|
||||
bench.profileend()
|
||||
|
||||
setSrc(updateRandomly(updateChance))
|
||||
setSrc(updatePercentage(percent))
|
||||
|
||||
bench.profilebegin("update")
|
||||
out()
|
||||
bench.profileend()
|
||||
end)
|
||||
|
||||
bench.run(`control {updateChance}`, function()
|
||||
bench.run(`control {percent}`, function()
|
||||
local src, setSrc = signal(initialState)
|
||||
local out = simpleMapped(src, mapper)
|
||||
|
||||
|
|
@ -75,7 +74,7 @@ for _, updateChance in { 0.1, 0.5, 1 } do
|
|||
out()
|
||||
bench.profileend()
|
||||
|
||||
setSrc(updateRandomly(updateChance))
|
||||
setSrc(updatePercentage(percent))
|
||||
|
||||
bench.profilebegin("update")
|
||||
out()
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
local Charm = require("../packages/charm/src")
|
||||
local bench = require("./bench")
|
||||
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
local signal = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
|
||||
local function busy(): number
|
||||
local a = 0
|
||||
for i = 1, 100 do
|
||||
a += 1
|
||||
end
|
||||
return a
|
||||
end
|
||||
|
||||
do
|
||||
local head = signal(0)
|
||||
local computed1 = computed(function()
|
||||
return head()
|
||||
end)
|
||||
local computed2 = computed(function()
|
||||
computed1()
|
||||
return 0
|
||||
end)
|
||||
local computed3 = computed(function()
|
||||
busy() -- heavy computation
|
||||
return computed2() + 1
|
||||
end)
|
||||
local computed4 = computed(function()
|
||||
return computed3() + 2
|
||||
end)
|
||||
local computed5 = computed(function()
|
||||
return computed4() + 3
|
||||
end)
|
||||
effect(function()
|
||||
computed5()
|
||||
busy() -- heavy side effect
|
||||
end)
|
||||
|
||||
bench.run("avoidablePropagation", function()
|
||||
head(1)
|
||||
assert(computed5() == 6)
|
||||
for i = 0, 999 do
|
||||
head(i)
|
||||
end
|
||||
assert(computed5() == 6)
|
||||
end)
|
||||
end
|
||||
|
||||
do
|
||||
local head = signal(0)
|
||||
local double = computed(function()
|
||||
return head() * 2
|
||||
end)
|
||||
local inverse = computed(function()
|
||||
return -head()
|
||||
end)
|
||||
local current = computed(function()
|
||||
local result = 0
|
||||
for i = 0, 19 do
|
||||
result += if head() % 2 == 1 then double() else inverse()
|
||||
end
|
||||
return result
|
||||
end)
|
||||
|
||||
local callCounter = 0
|
||||
effect(function()
|
||||
current()
|
||||
callCounter += 1
|
||||
end)
|
||||
|
||||
bench.run("unstable", function()
|
||||
head(1)
|
||||
assert(current() == 40)
|
||||
callCounter = 0
|
||||
for i = 0, 99 do
|
||||
head(i)
|
||||
end
|
||||
assert(callCounter == 100)
|
||||
end)
|
||||
end
|
||||
|
||||
do
|
||||
local head = signal(0)
|
||||
local double = computed(function()
|
||||
return head() * 2
|
||||
end)
|
||||
local inverse = computed(function()
|
||||
return -head()
|
||||
end)
|
||||
local current = computed(function()
|
||||
local result = 0
|
||||
for i = 0, 19 do
|
||||
result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse()
|
||||
end
|
||||
return result
|
||||
end)
|
||||
|
||||
local callCounter = 0
|
||||
effect(function()
|
||||
current()
|
||||
callCounter += 1
|
||||
end)
|
||||
|
||||
bench.run("unstable 50%", function()
|
||||
head(1)
|
||||
assert(current() == 10)
|
||||
callCounter = 0
|
||||
for i = 0, 99 do
|
||||
head(i)
|
||||
end
|
||||
assert(callCounter == 100)
|
||||
end)
|
||||
end
|
||||
|
||||
do
|
||||
local head = signal(0)
|
||||
local last: () -> number = head
|
||||
local callCounter = 0
|
||||
|
||||
for i = 0, 49 do
|
||||
local current = computed(function()
|
||||
return head() + i
|
||||
end)
|
||||
local current2 = computed(function()
|
||||
return current() + 1
|
||||
end)
|
||||
effect(function()
|
||||
current2()
|
||||
callCounter += 1
|
||||
end)
|
||||
last = current2
|
||||
end
|
||||
|
||||
bench.run("broadPropagation", function()
|
||||
head(1)
|
||||
callCounter = 0
|
||||
for i = 0, 49 do
|
||||
head(i)
|
||||
assert(last() == i + 50)
|
||||
end
|
||||
assert(callCounter == 50 * 50)
|
||||
end)
|
||||
end
|
||||
|
||||
do
|
||||
local heads = {}
|
||||
for index = 0, 99 do
|
||||
heads[index] = signal(0)
|
||||
end
|
||||
|
||||
local mux = computed(function()
|
||||
local result = {}
|
||||
for index = 0, 99 do
|
||||
result[index] = heads[index]()
|
||||
end
|
||||
return result
|
||||
end)
|
||||
|
||||
local splited = {}
|
||||
for index = 0, 99 do
|
||||
local x = computed(function()
|
||||
return mux()[index]
|
||||
end)
|
||||
splited[index] = computed(function()
|
||||
return x() + 1
|
||||
end)
|
||||
end
|
||||
|
||||
for _, x in splited do
|
||||
effect(function()
|
||||
x()
|
||||
end)
|
||||
end
|
||||
|
||||
bench.run("mux", function()
|
||||
for i = 0, 9 do
|
||||
heads[i](i)
|
||||
assert(splited[i]() == i + 1)
|
||||
end
|
||||
for i = 0, 9 do
|
||||
heads[i](i * 2)
|
||||
assert(splited[i]() == i * 2 + 1)
|
||||
end
|
||||
end)
|
||||
end
|
||||
20
package.json
20
package.json
|
|
@ -1,29 +1,29 @@
|
|||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "lute run test",
|
||||
"test": "lute run tests",
|
||||
"build": "lute run build",
|
||||
"check": "lute run check",
|
||||
"publish:npm": "pnpm publish -r --access public",
|
||||
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
|
||||
},
|
||||
"packageManager": "pnpm@12.8.1",
|
||||
"packageManager": "pnpm@10.30.2",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@rbxts/compiler-types": "3.0.0-types.0",
|
||||
"@rbxts/react": "17.3.7-ts.1",
|
||||
"@rbxts/react-roblox": "17.3.7-ts.1",
|
||||
"@rbxts/types": "^1.0.913",
|
||||
"@rbxts/vide": "^0.6.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"eslint": "^9.39.4",
|
||||
"@rbxts/types": "^1.0.908",
|
||||
"@rbxts/vide": "^0.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-roblox-ts": "^1.4.0",
|
||||
"eslint-plugin-roblox-ts": "^1.3.1",
|
||||
"prettier": "^3.8.1",
|
||||
"roblox-ts": "^3.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.0"
|
||||
"typescript-eslint": "^8.56.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@rbxts/charm-sync",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0-rc.6",
|
||||
"description": "Simple server-to-client sync for Charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ local patch = require("./patch")
|
|||
local types = require("./types")
|
||||
|
||||
type SyncPayload = types.SyncPayload
|
||||
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
|
||||
type ReactiveProxy<T = {}> = types.ReactiveProxy<T>
|
||||
|
||||
local trackedSignals: { [string]: (any) -> any } = {}
|
||||
local trackedReactives: { [string]: ReactiveProxy } = {}
|
||||
|
|
@ -64,8 +64,6 @@ local function applyInitPayload(data: { [string]: any })
|
|||
trackedSignals[key](value)
|
||||
elseif trackedReactives[key] then
|
||||
shallowMergeReactive(trackedReactives[key], value)
|
||||
else
|
||||
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -82,8 +80,6 @@ local function applyPatchPayload(data: { [string]: any })
|
|||
elseif trackedReactive then
|
||||
patch.applyMutable(trackedReactive.__target, diffs)
|
||||
Charm.trigger(trackedReactive.__track)
|
||||
else
|
||||
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -94,7 +90,7 @@ end
|
|||
|
||||
@param payloads The state updates received from the server.
|
||||
]]
|
||||
local function applyPatches(payloads: { SyncPayload })
|
||||
local function patch(payloads: { SyncPayload })
|
||||
for _, payload in payloads do
|
||||
if payload.type == "init" then
|
||||
Charm.batch(applyInitPayload, payload.data)
|
||||
|
|
@ -108,5 +104,5 @@ return {
|
|||
addSignals = addSignals,
|
||||
removeSignals = removeSignals,
|
||||
removeAllSignals = removeAllSignals,
|
||||
patch = applyPatches,
|
||||
patch = patch,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,19 @@ return {
|
|||
Whether the history of state changes since the client's last update
|
||||
should be preserved. This is useful for values that change multiple
|
||||
times per frame, where each individual change is important. Defaults
|
||||
to `false` for performance reasons.
|
||||
to `false`.
|
||||
]]
|
||||
preserveHistory = false,
|
||||
--[[
|
||||
When `true`, Charm will modify sparse arrays to prevent data loss from
|
||||
remote event limitations. When an unsafe array is detected, numeric
|
||||
indices will be converted to string keys so that the array can be sent
|
||||
safely and then converted back to an array on the receiving end.
|
||||
|
||||
When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
to address remote event argument limitations. Defaults to `true`.
|
||||
This option should be disabled if your network library uses a custom
|
||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
]]
|
||||
fixArrays = true,
|
||||
--[[
|
||||
When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
tables will emit a warning. Only checked in strict mode and if
|
||||
tables will throw an error. Only checked in strict mode and if
|
||||
`fixArrays` is enabled. Defaults to `true`.
|
||||
]]
|
||||
validatePatches = true,
|
||||
|
|
|
|||
13
packages/charm-sync/src/index.d.ts
vendored
13
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -78,17 +78,14 @@ export const config: {
|
|||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple
|
||||
* times per frame, where each individual change is important. Defaults
|
||||
* to `false` for performance reasons.
|
||||
* to `false`.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
preserveHistory: boolean;
|
||||
/**
|
||||
* When `true`, Charm will modify sparse arrays to prevent data loss from
|
||||
* remote event limitations. When an unsafe array is detected, numeric
|
||||
* indices will be converted to string keys so that the array can be sent
|
||||
* safely and then converted back to an array on the receiving end.
|
||||
*
|
||||
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
* to address remote event argument limitations. Defaults to `true`.
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*
|
||||
|
|
@ -97,7 +94,7 @@ export const config: {
|
|||
fixArrays: boolean;
|
||||
/**
|
||||
* When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
* tables will emit a warning. Only checked in strict mode and if
|
||||
* tables will throw an error. Only checked in strict mode and if
|
||||
* `fixArrays` is enabled. Defaults to `true`.
|
||||
*
|
||||
* @server
|
||||
|
|
@ -245,7 +242,7 @@ export namespace patch {
|
|||
* @param statePatch The patches to apply.
|
||||
* @returns The new state with the patch applied.
|
||||
*/
|
||||
export function applyImmutable<State, FixArrays extends boolean = true>(
|
||||
export function apply<State, FixArrays extends boolean = true>(
|
||||
currentState: State,
|
||||
statePatch: SyncPatch<State, FixArrays>,
|
||||
): State;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ end
|
|||
long as the target array is not sparse. This function ensures that the diff
|
||||
function produces a patch that can be correctly applied.
|
||||
]]
|
||||
local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
|
||||
local function stringifySparseArray(object: {}): { [string | number]: any }
|
||||
local maxn = table.maxn(object)
|
||||
|
||||
if maxn == 0 or maxn == #object then
|
||||
|
|
|
|||
|
|
@ -39,10 +39,9 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
|
|||
support custom implementations of reactive proxies.
|
||||
]]
|
||||
local function toRaw<T>(value: T): T
|
||||
local proxy = value :: types.ReactiveProxy<T>
|
||||
if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
|
||||
proxy.__track()
|
||||
return toRaw(proxy.__target)
|
||||
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then
|
||||
value.__track()
|
||||
return toRaw(value.__target)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
|
@ -61,7 +60,7 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
|
|||
local rawInitialValue = toRaw(initialValue)
|
||||
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
|
||||
|
||||
local dispose = Charm.untracked(Charm.effect, function()
|
||||
local dispose = Charm.effect(function()
|
||||
local newValue = getter()
|
||||
local rawValue = toRaw(newValue)
|
||||
local isReactive = newValue ~= rawValue
|
||||
|
|
@ -125,8 +124,8 @@ local function diffGlobalUpdateBuffer(): { any }
|
|||
local patchCount = #globalUpdates
|
||||
local patches = table.create(patchCount)
|
||||
|
||||
for index, update in globalUpdates do
|
||||
patches[index] = patch.diff(update.oldValue, update.newValue)
|
||||
for _, update in globalUpdates do
|
||||
table.insert(patches, patch.diff(update.oldValue, update.newValue))
|
||||
end
|
||||
|
||||
return patches
|
||||
|
|
@ -146,7 +145,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
|||
for index, update in globalUpdates do
|
||||
local patchData = globalPatches[index]
|
||||
|
||||
if patchData == nil then
|
||||
if not patchData then
|
||||
continue
|
||||
end
|
||||
|
||||
|
|
@ -154,17 +153,15 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
|||
local status = clientKeys[key]
|
||||
|
||||
if status == LISTENING_FOR_CHANGES then
|
||||
local payload: SyncPayload = {
|
||||
table.insert(payloads, {
|
||||
type = "patch",
|
||||
data = { [key] = patchData },
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
})
|
||||
elseif status == PENDING_INITIAL_STATE then
|
||||
local payload: SyncPayload = {
|
||||
table.insert(payloads, {
|
||||
type = "init",
|
||||
data = { [key] = update.newValue },
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
})
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
|
@ -231,19 +228,11 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
|
|||
end
|
||||
|
||||
if next(initData) ~= nil then
|
||||
local payload: SyncPayload = {
|
||||
type = "init",
|
||||
data = initData,
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
table.insert(payloads, { type = "init", data = initData })
|
||||
end
|
||||
|
||||
if next(patchData) ~= nil then
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patchData,
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
table.insert(payloads, { type = "patch", data = patchData })
|
||||
end
|
||||
|
||||
return payloads
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ local Charm = require("../Charm")
|
|||
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
type NestedTable = {
|
||||
[any]: NestedTable | any,
|
||||
}
|
||||
|
||||
--[[
|
||||
Flattens a nested table into a single table with slash-separated keys.
|
||||
Useful for recursively collecting atoms returned by modules.
|
||||
|
|
@ -9,15 +13,15 @@ type Atom<T> = Charm.Atom<T>
|
|||
@param input A nested table.
|
||||
@return A flattened table with slash-separated keys.
|
||||
]]
|
||||
local function flatten<Flattened>(input: { [any]: any }): Flattened
|
||||
local function flatten<Flattened>(input: {}): Flattened
|
||||
local result = {}
|
||||
|
||||
local function visit(node: { [any]: any }, path: string)
|
||||
local function visit(node: {}, path: string)
|
||||
for key, value in node do
|
||||
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
|
||||
|
||||
if type(value) == "table" then
|
||||
visit(value, location)
|
||||
visit(value :: {}, location)
|
||||
else
|
||||
result[location] = value
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
local function isTableUnsafe(object: { [any]: any })
|
||||
local firstKey = next(object)
|
||||
local function isTableUnsafe(object: {})
|
||||
local key = next(object)
|
||||
|
||||
if firstKey == nil then
|
||||
if key == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
local keyType = type(firstKey)
|
||||
local keyType = type(key)
|
||||
local maxKey = 0
|
||||
|
||||
-- Keys must all be the same type
|
||||
|
|
@ -31,11 +31,13 @@ end
|
|||
with remote argument limitations, such as sparse arrays or mixed tables.
|
||||
|
||||
@param value The table value.
|
||||
@param key The key of the value.
|
||||
]]
|
||||
local function validate(value: { [any]: any })
|
||||
local function validate(value: {})
|
||||
if isTableUnsafe(value) then
|
||||
warn(
|
||||
"Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
|
||||
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
|
||||
.. "This can be for the following reasons:\n"
|
||||
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ local config = require("../src/config")
|
|||
local patch = require("../src/patch")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||
local function mockArraySerdes(object: {}): {}
|
||||
if object[1] then
|
||||
local copy = {}
|
||||
for index = 1, #object do
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ local config = require("../src/config")
|
|||
local patch = require("../src/patch")
|
||||
local server = require("../src/server")
|
||||
local test = require("../../../tests/test")
|
||||
local types = require("../src/types")
|
||||
|
||||
test.afterEach(function()
|
||||
server.reset()
|
||||
|
|
@ -15,7 +14,7 @@ test("should replicate state changes", function()
|
|||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local c = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
|
|
@ -42,7 +41,7 @@ test("should replicate partial state patches", function()
|
|||
local client = {}
|
||||
local a = Charm.atom({ x = 0, y = 0 })
|
||||
local b = Charm.atom(0 :: number?)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b })
|
||||
server.connect(function(...)
|
||||
|
|
@ -69,7 +68,7 @@ end)
|
|||
test("should replicate partial state patches (reactive)", function()
|
||||
local client = {}
|
||||
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(...)
|
||||
|
|
@ -119,7 +118,7 @@ test("should init new clients", function()
|
|||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
local payloadsA, payloadsB
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.connect(function(target, payloads)
|
||||
|
|
@ -148,7 +147,7 @@ test("should init new atoms", function()
|
|||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(...)
|
||||
|
|
@ -173,7 +172,7 @@ test("should only replicate to synced clients", function()
|
|||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
local payloadsA, payloadsB
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.addSignalsToClient(clientB, { b = b })
|
||||
|
|
@ -215,7 +214,7 @@ end)
|
|||
test("should serialize state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||
local payloads: { types.SyncPayload }
|
||||
local payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
|
|
@ -232,7 +231,7 @@ end)
|
|||
test("should not send unchanged state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
local payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
|
|
@ -263,7 +262,7 @@ test.describe("historical", function()
|
|||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local c = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
|
|
@ -293,7 +292,7 @@ test.describe("historical", function()
|
|||
local a = DeepCharm.reactive({ value = 0 })
|
||||
local b = DeepCharm.reactive({ value = 0 })
|
||||
local c = DeepCharm.reactive({ value = 0 })
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
|
|
@ -322,7 +321,7 @@ test.describe("historical", function()
|
|||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
local payloadsA, payloadsB
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.connect(function(target, payloads)
|
||||
|
|
@ -358,7 +357,7 @@ test.describe("historical", function()
|
|||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
local payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, ...)
|
||||
|
|
@ -387,7 +386,7 @@ test.describe("historical", function()
|
|||
test("should not send unchanged state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
local payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
|
|
@ -403,54 +402,6 @@ test.describe("historical", function()
|
|||
server.flush()
|
||||
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
|
||||
end)
|
||||
|
||||
test("should maintain patch list order for stale update", function()
|
||||
local a = Charm.atom({ x = 1 })
|
||||
local b = Charm.atom({ y = 1 })
|
||||
local payloads
|
||||
|
||||
server.connect(function(_client, _payloads)
|
||||
payloads = _payloads
|
||||
end)
|
||||
|
||||
server.addSignalsToClient("client", { a = a, b = b })
|
||||
server.flush()
|
||||
|
||||
payloads = nil
|
||||
a({ x = 1 }) -- new table, same contents: diff is nil
|
||||
b({ y = 2 })
|
||||
server.flush()
|
||||
assert(payloads, "Expected payloads to be sent")
|
||||
assert(payloads[1].type == "patch", "Expected payload to be patch")
|
||||
assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'")
|
||||
end)
|
||||
end)
|
||||
|
||||
test("addSignalsToClient should ignore tracking context", function()
|
||||
local counter = Charm.atom(0)
|
||||
local payloads: { any }?
|
||||
|
||||
server.connect(function(_client, _payloads)
|
||||
payloads = _payloads
|
||||
end)
|
||||
|
||||
local dispose = Charm.effectScope(function()
|
||||
server.addSignalsToClient("PLAYER_ID", { counter = counter })
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(payloads, "Expected payloads to be sent")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'")
|
||||
|
||||
dispose()
|
||||
|
||||
payloads = nil
|
||||
counter(1)
|
||||
server.flush()
|
||||
assert(payloads, "Expected payloads to be sent")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ test("should flatten functions", function()
|
|||
local input = {
|
||||
a = Charm.atom(0),
|
||||
b = { c = Charm.atom(0) },
|
||||
d = { e = { f = utils.signalToAtom<<number>>(Charm.signal(0)) } },
|
||||
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
|
||||
}
|
||||
|
||||
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
|
||||
local result = utils.flatten(input)
|
||||
|
||||
assert(result.a == input.a, "should flatten the first level")
|
||||
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "littensy/charm-sync"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0-rc.6"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
|
|
@ -12,4 +12,4 @@ exclude = [
|
|||
]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0"
|
||||
Charm = "littensy/charm@^0.11.0-rc.5"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@rbxts/charm",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.0-rc.5",
|
||||
"description": "Atomic state management based on alien-signals",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
|
|
|
|||
43
packages/charm/src/index.d.ts
vendored
43
packages/charm/src/index.d.ts
vendored
|
|
@ -50,12 +50,12 @@ export const flags: {
|
|||
/**
|
||||
* Enforces synchronous, non-yielding behavior in signals and effects.
|
||||
* Also enables state validation in Charm Sync to catch sync errors early.
|
||||
* Enabled by default in Roblox Studio.
|
||||
* Enabled if the optimization level is below 2, which is true in studio.
|
||||
*/
|
||||
strict: boolean;
|
||||
/**
|
||||
* Enforces immutability of tables by deep-freezing table values. Enabled
|
||||
* by default in Roblox Studio.
|
||||
* if the optimization level is below 2, which is true in studio.
|
||||
*/
|
||||
frozen: boolean;
|
||||
/**
|
||||
|
|
@ -179,50 +179,53 @@ export function batch<Args extends any[], Result>(fn: (...args: Args) => Result,
|
|||
* when the value returned by the getter changes. The callback receives the new
|
||||
* value and the previous value as arguments.
|
||||
*
|
||||
* Effects created within the callback are cleaned up when the listener runs
|
||||
* or the listener is disposed.
|
||||
*
|
||||
* @param getter A function that returns the value to watch.
|
||||
* @param callback The function to run when the value changes.
|
||||
* @param fn The function to run when the value changes.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||
*/
|
||||
export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
|
||||
export function listen<T>(getter: Getter<T>, fn: (value: T, previousValue: T | undefined) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates an effect that only runs the callback when the value returned by the
|
||||
* getter changes. The callback receives the new value and the previous value
|
||||
* as arguments.
|
||||
*
|
||||
* Effects created within the callback are cleaned up when the listener runs
|
||||
* or the listener is disposed.
|
||||
*
|
||||
* @param getter A function that returns the value to watch.
|
||||
* @param callback The function to run when the value changes.
|
||||
* @param fn The function to run when the value changes.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||
*/
|
||||
export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
|
||||
export function subscribe<T>(getter: Getter<T>, fn: (value: T, previousValue: T) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates a new read-only signal that is computed by mapping over the entries
|
||||
* of the source table. If the key is omitted from the transform function's
|
||||
* return value, the original key is preserved.
|
||||
* of an existing atom. If the key is omitted from the mapper's return value,
|
||||
* the original key is preserved.
|
||||
*
|
||||
* This function is optimized to minimize unnecessary updates by only calling
|
||||
* the transform function for entries that have changed since the last run.
|
||||
*
|
||||
* @param getter A signal or function that returns a table
|
||||
* @param transform Function that maps each entry from the source to a new entry in the mapped table
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
|
||||
* @param getter Returns the table to map over.
|
||||
* @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
|
||||
*/
|
||||
export function mapped<VI, KI, VO, KO = KI>(
|
||||
getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
|
||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): Getter<ReadonlyMap<KO, VO>>;
|
||||
// Overload for arrays
|
||||
export function mapped<VI, VO, K extends Key = number>(
|
||||
getter: Getter<readonly VI[]>,
|
||||
transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
|
||||
// Overload for objects
|
||||
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||
getter: Getter<{ readonly [K in KI]: VI }>,
|
||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): Getter<{ readonly [K in KO]: VO }>;
|
||||
|
||||
/**
|
||||
|
|
@ -237,18 +240,18 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
|||
* from the table or when the callback is disposed.
|
||||
*
|
||||
* @param getter A function that returns the table to observe.
|
||||
* @param callback A function that is called for each key in the table.
|
||||
* @param fn A function that is called for each key in the table.
|
||||
* @returns A function for disposing the observer.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||
*/
|
||||
export function observe<V, K = number>(
|
||||
getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
|
||||
callback: (value: V, key: K) => Cleanup | void,
|
||||
fn: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
// Overload for objects
|
||||
export function observe<V, K extends Key>(
|
||||
getter: Getter<{ readonly [K in Key]: V }>,
|
||||
callback: (value: V, key: K) => Cleanup | void,
|
||||
fn: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -102,7 +102,7 @@ local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
|
|||
end
|
||||
|
||||
local function unlink(link: Link, sub: ReactiveNode?): Link?
|
||||
sub = sub or link.sub
|
||||
local sub = sub or link.sub
|
||||
local dep = link.dep
|
||||
local prevDep = link.prevDep
|
||||
local nextDep = link.nextDep
|
||||
|
|
@ -149,6 +149,7 @@ local function propagate(link: Link)
|
|||
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
|
||||
flags = bit32.band(flags, MUTABLE)
|
||||
else
|
||||
-- selene: allow(if_same_then_else)
|
||||
flags = NONE
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ end)
|
|||
test("should receive previous value", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function(current: number?): number
|
||||
local c1 = computed(function(current): number
|
||||
local incoming = src()
|
||||
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
|
||||
end)
|
||||
|
|
@ -192,29 +192,10 @@ test("should trigger effect even computed already dirty", function()
|
|||
table.insert(calls, c2())
|
||||
end)
|
||||
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
|
||||
assert(v() == 1, `expected 1 for v(), got {v()}`)
|
||||
assert(v() == 1, v())
|
||||
v(2)
|
||||
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
|
||||
assert(v() == 2, `expected 2 for v(), got {v()}`)
|
||||
end)
|
||||
|
||||
test("should handle inner effect clearing outer computed's deps on rerun", function()
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
local c = computed(function()
|
||||
src()
|
||||
effect(function()
|
||||
return function()
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
effect(function() end)
|
||||
return 0
|
||||
end)
|
||||
stopEffect = effect(function()
|
||||
c()
|
||||
end)
|
||||
setSrc(true)
|
||||
assert(v() == 2, v())
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -239,12 +239,9 @@ test("should handle side effect with inner effects", function()
|
|||
table.clear(order)
|
||||
setB(1)
|
||||
setA(1)
|
||||
end)
|
||||
|
||||
-- DEVIATION: This assertion was moved outside of the effect so that it
|
||||
-- runs after the batch updates have been processed
|
||||
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a, setA = signal(false)
|
||||
|
|
@ -271,48 +268,7 @@ test("should handle flags are indirectly updated during checkDirty", function()
|
|||
assert(triggers == 2, "did not update indirectly")
|
||||
end)
|
||||
|
||||
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c, setC = signal(0)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return 0
|
||||
end)
|
||||
|
||||
local effect3Executed = false
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
end)
|
||||
effect(function()
|
||||
if a() == 2 then
|
||||
error("Error in effect 2")
|
||||
end
|
||||
end)
|
||||
effect(function()
|
||||
a()
|
||||
d()
|
||||
effect3Executed = true
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
|
||||
effect3Executed = false
|
||||
local success, err = pcall(setA, 2)
|
||||
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
|
||||
|
||||
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
|
||||
setB(1)
|
||||
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
|
||||
setC(1)
|
||||
assert(effect3Executed, "effect 3 should have executed after updating signal c")
|
||||
end)
|
||||
|
||||
test("should allow finite recursion", function()
|
||||
test("should allow recursion", function()
|
||||
local src, setSrc = signal(0)
|
||||
local order = {}
|
||||
|
||||
|
|
@ -408,186 +364,15 @@ test("should run cleanups outside any evaluation context", function()
|
|||
assert(calls == 0, "should not run after b update")
|
||||
end)
|
||||
|
||||
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = {}
|
||||
test("infinite recursion should eventually throw", function()
|
||||
local counter, setCounter = signal(0)
|
||||
|
||||
local success = pcall(function()
|
||||
effect(function()
|
||||
table.insert(calls, a())
|
||||
return function()
|
||||
setA(2)
|
||||
end
|
||||
end)
|
||||
assert(#calls == 1, "should have run once initially")
|
||||
table.clear(calls)
|
||||
|
||||
setA(1)
|
||||
assert(#calls == 1, "should run once after an update")
|
||||
assert(calls[1] == 2, `bad call: {calls[1]}`)
|
||||
end)
|
||||
|
||||
test("should run the cleanup if the effect disposes itself", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = effect(function()
|
||||
if a() > 0 then
|
||||
dispose()
|
||||
return function()
|
||||
calls += 1
|
||||
end
|
||||
end
|
||||
end)
|
||||
assert(calls == 0, "should not have called cleanup initially")
|
||||
setA(1)
|
||||
assert(calls == 1, "should have called cleanup once after first update")
|
||||
setA(2)
|
||||
assert(calls == 1, "should not call cleanup again after second update")
|
||||
end)
|
||||
|
||||
test("should not run the effect if the cleanup function disposes it", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = effect(function()
|
||||
a()
|
||||
calls += 1
|
||||
return function()
|
||||
dispose()
|
||||
end
|
||||
end)
|
||||
assert(calls == 1, "should have run effect once initially")
|
||||
setA(1)
|
||||
assert(calls == 1, "should not run effect again after update")
|
||||
end)
|
||||
|
||||
test("should reset the cleanup if the effect throws", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
|
||||
effect(function()
|
||||
if a() == 0 then
|
||||
return function()
|
||||
calls += 1
|
||||
end
|
||||
else
|
||||
error("hello")
|
||||
end
|
||||
end)
|
||||
assert(calls == 0, "should not have called cleanup initially")
|
||||
local success, err = pcall(setA, 1)
|
||||
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
|
||||
assert(calls == 1, "should have called cleanup once after throwing")
|
||||
setA(0)
|
||||
assert(calls == 1, "should not call cleanup again after resetting value")
|
||||
end)
|
||||
|
||||
test("recursive effects should run and cleanup in order", function()
|
||||
local a, setA = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
local value = a()
|
||||
table.insert(order, `+{value}`)
|
||||
if value < 3 then
|
||||
setA(value + 1)
|
||||
end
|
||||
return function()
|
||||
table.insert(order, `-{value}`)
|
||||
end
|
||||
end)
|
||||
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
setA(100)
|
||||
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should effects dispose in order", function()
|
||||
local order: { number } = {}
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
src()
|
||||
for i = 1, 3 do
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, i)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(#order == 0, "should not cleanup immediately")
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
stopEffect()
|
||||
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
setSrc(false)
|
||||
assert(#order == 0, "should not cleanup after dispose")
|
||||
end)
|
||||
|
||||
test("should handle inner effect clearing outer effect's deps on rerun", function()
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
stopEffect = effect(function()
|
||||
src()
|
||||
effect(function()
|
||||
return function()
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
effect(function() end)
|
||||
end)
|
||||
setSrc(true)
|
||||
end)
|
||||
|
||||
test.skip("should handle inner effect cleanup disposing outer effect", function()
|
||||
local order: { number } = {}
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
stopEffect = effect(function()
|
||||
src()
|
||||
table.insert(order, 0)
|
||||
for i = 1, 4 do
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, i)
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
assert(table.concat(order) == "0", "should not cleanup immediately")
|
||||
|
||||
table.clear(order)
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setSrc(false)
|
||||
assert(#order == 0, "should not cleanup after dispose")
|
||||
end)
|
||||
|
||||
test.skip("should old inner effect dispose before outer effect re-runs", function()
|
||||
local order: { string } = {}
|
||||
local src, setSrc = signal(false)
|
||||
effect(function()
|
||||
src()
|
||||
table.insert(order, "outer")
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, "inner")
|
||||
end
|
||||
setCounter(counter() + 1)
|
||||
end)
|
||||
end)
|
||||
table.clear(order)
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`)
|
||||
assert(not success, "should have thrown")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ end)
|
|||
|
||||
test("should run indirect scope cleanup", function()
|
||||
local cleanups = 0
|
||||
local cleanup: () -> ()
|
||||
|
||||
local cleanup
|
||||
local stopScope = effectScope(function()
|
||||
cleanup = effectScope(function()
|
||||
onCleanup(function()
|
||||
|
|
@ -50,8 +50,8 @@ end)
|
|||
|
||||
test("should run indirect effect cleanup", function()
|
||||
local cleanups = 0
|
||||
local a, setA = signal(false)
|
||||
|
||||
local a, setA = signal(false)
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
a()
|
||||
|
|
@ -70,26 +70,13 @@ test("should run indirect effect cleanup", function()
|
|||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should dispose inner effects if created in an effect", function()
|
||||
local source, setSource = signal(1)
|
||||
local triggers = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
effect(function()
|
||||
dispose = effectScope(function()
|
||||
effect(function()
|
||||
source()
|
||||
triggers += 1
|
||||
test("should disallow yielding", function()
|
||||
local success = pcall(function()
|
||||
effectScope(function()
|
||||
(if task then task.wait else coroutine.yield :: never)()
|
||||
end)
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
end)
|
||||
|
||||
setSource(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
dispose()
|
||||
setSource(3)
|
||||
assert(triggers == 2, "ran after dispose")
|
||||
assert(not success, "should error on yield")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
--!optimize 1
|
||||
|
||||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("should disallow yielding", function()
|
||||
local success = pcall(function()
|
||||
Charm.effectScope(function()
|
||||
coroutine.yield()
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error on yield")
|
||||
end)
|
||||
|
||||
test.describe("traceback", function()
|
||||
local DEFAULT_ERROR = "Test error"
|
||||
|
||||
local function fail(message: string?)
|
||||
error(message or DEFAULT_ERROR, 0)
|
||||
end
|
||||
|
||||
local function indirectError(message: string?)
|
||||
fail(message)
|
||||
end
|
||||
|
||||
local function assertStackTrace(err: unknown, expectation: string?)
|
||||
expectation = expectation or DEFAULT_ERROR
|
||||
assert(type(err) == "string", "Expected error to be a string")
|
||||
assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`)
|
||||
assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`)
|
||||
assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`)
|
||||
end
|
||||
|
||||
test("signal update", function()
|
||||
local _, err = pcall(function()
|
||||
local _, setSrc = Charm.signal(0)
|
||||
setSrc(function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("atom update", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.atom(0)
|
||||
src(function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("computed", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.computed(function()
|
||||
indirectError()
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect init", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.effect(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect run", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.effect(function()
|
||||
if src() == 1 then
|
||||
indirectError()
|
||||
end
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect cleanups", function()
|
||||
local _, err = pcall(function()
|
||||
local dispose = Charm.effect(function()
|
||||
Charm.onCleanup(function()
|
||||
indirectError("onCleanup")
|
||||
end)
|
||||
return function()
|
||||
indirectError("returned cleanup")
|
||||
end
|
||||
end)
|
||||
dispose()
|
||||
end)
|
||||
assertStackTrace(err, "onCleanup")
|
||||
assertStackTrace(err, "returned cleanup")
|
||||
end)
|
||||
|
||||
test("trigger", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.trigger(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("untracked", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.effectScope(function()
|
||||
Charm.untracked(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("batch", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.batch(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("subscribe getter", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.subscribe(function()
|
||||
indirectError()
|
||||
end, function() end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("subscribe callback", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.subscribe(src, function()
|
||||
indirectError()
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("listen getter", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.listen(function()
|
||||
indirectError()
|
||||
end, function() end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("listen callback", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.listen(src, function()
|
||||
indirectError()
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("mapped getter", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.mapped(function()
|
||||
indirectError()
|
||||
return { 1 }
|
||||
end, function()
|
||||
return 1
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("mapped transform", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.mapped(function()
|
||||
return { 1 }
|
||||
end, function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -26,7 +26,7 @@ end
|
|||
immediately closing it, causing alien-signals to unlink the computed value
|
||||
from its dependencies and allow it to be garbage collected.
|
||||
]]
|
||||
local function unlink(getter: () -> unknown)
|
||||
local function unlink(getter: () -> ())
|
||||
effectScope(function()
|
||||
getter()
|
||||
end)()
|
||||
|
|
|
|||
|
|
@ -12,10 +12,6 @@ test.describe("effectScope", function()
|
|||
require("@self/effectScope.test")
|
||||
end)
|
||||
|
||||
test.describe("error", function()
|
||||
require("@self/errors.test")
|
||||
end)
|
||||
|
||||
test.describe("gc", function()
|
||||
require("@self/gc.test")
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -104,25 +104,4 @@ test("should map keys", function()
|
|||
assert(state.D == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should remove old key if mapper returns a new key", function()
|
||||
local src, setSrc = signal({ 1 })
|
||||
local map = mapped(src, function(value: number, index: number)
|
||||
return true, `{index}={value}`
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(state["1=1"], "should have mapped first element to key 1=1")
|
||||
|
||||
setSrc({ 2 })
|
||||
state = map()
|
||||
assert(state["1=1"] == nil, "should have removed old key 1=1")
|
||||
assert(state["1=2"], "should have remapped element to key 1=2")
|
||||
|
||||
setSrc({ 3, 1 })
|
||||
state = map()
|
||||
assert(state["1=2"] == nil, "should have removed old key 1=2")
|
||||
assert(state["1=3"], "should have remapped element to key 1=3")
|
||||
assert(state["2=1"], "should have mapped second element to key 2=1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ test("should observe items", function()
|
|||
local updates = {}
|
||||
local cleanups = {}
|
||||
|
||||
observe(items, function(_, key: number)
|
||||
observe(items, function(_, key)
|
||||
local get = computed(function()
|
||||
return items()[key]
|
||||
end)
|
||||
|
|
@ -91,11 +91,11 @@ end)
|
|||
|
||||
test("should handle recursive additions and removals", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits: { string } = {}
|
||||
local edits = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item < 5 then
|
||||
setItems(function(state: { number })
|
||||
setItems(function(state)
|
||||
state = table.clone(state)
|
||||
table.insert(state, item + 1)
|
||||
return state
|
||||
|
|
@ -114,10 +114,10 @@ end)
|
|||
|
||||
test("should handle self removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits: { string } = {}
|
||||
local edits = {}
|
||||
|
||||
observe(items, function(item, index)
|
||||
setItems(function(state: { number })
|
||||
setItems(function(state)
|
||||
state = table.clone(state)
|
||||
state[index] = nil
|
||||
return state
|
||||
|
|
@ -134,7 +134,7 @@ end)
|
|||
test("should allow disposal during addition", function()
|
||||
local items, setItems = signal({})
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
local dispose
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
if item == 1 then
|
||||
|
|
@ -153,7 +153,7 @@ end)
|
|||
test("should allow disposal during removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
local dispose
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
table.insert(edits, `+{item}`)
|
||||
|
|
@ -167,32 +167,4 @@ test("should allow disposal during removal", function()
|
|||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive update", function()
|
||||
local items, setItems = signal({})
|
||||
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
|
||||
local success, err = pcall(function()
|
||||
setItems({ {} })
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive init", function()
|
||||
local items, setItems = signal({ {} })
|
||||
|
||||
local success, err = pcall(function()
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ local effect = Charm.effect
|
|||
|
||||
test("should accept equality function", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0, function(current: number, incoming: number)
|
||||
local src, setSrc = signal(0, function(current, incoming)
|
||||
return current == incoming or incoming % 2 == 1
|
||||
end)
|
||||
|
||||
|
|
@ -46,22 +46,4 @@ test("should not freeze values with metatables", function()
|
|||
)
|
||||
end)
|
||||
|
||||
test("should setter return same value as getter even if equal", function()
|
||||
local src, setSrc = signal(0, function()
|
||||
return true
|
||||
end)
|
||||
assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`)
|
||||
end)
|
||||
|
||||
test("should setter return latest value", function()
|
||||
local src, setSrc = signal(0)
|
||||
effect(function()
|
||||
if src() == 1 then
|
||||
setSrc(2)
|
||||
end
|
||||
end)
|
||||
assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`)
|
||||
assert(src() == 2, `src() expected 2, got {src()}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ test("should run in untracked", function()
|
|||
end)
|
||||
|
||||
test("should ensure correct execution order", function()
|
||||
local order: { number } = {}
|
||||
local order = {}
|
||||
local n1, setN1 = signal(0)
|
||||
local n2, setN2 = signal(0)
|
||||
local sum = computed(function()
|
||||
|
|
@ -72,7 +72,7 @@ test("should ensure correct execution order", function()
|
|||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order: { "even" | "odd" } = {}
|
||||
local order = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
subscribe(function(): "even" | "odd"
|
||||
|
|
|
|||
|
|
@ -442,16 +442,18 @@ end)
|
|||
test.describe("error handling", function()
|
||||
test("should keep graph consistent on errors during activation", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): nil
|
||||
local b = computed(function()
|
||||
error("fail")
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local success, result = pcall(b)
|
||||
local success, result = pcall(function()
|
||||
b()
|
||||
end)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
assert(string.match(result, "fail"), "should throw fail")
|
||||
|
||||
a(1)
|
||||
assert(c() == 1, "c should recover and return 1")
|
||||
|
|
@ -459,7 +461,7 @@ test.describe("error handling", function()
|
|||
|
||||
test("should keep graph consistent on errors in computeds", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): number
|
||||
local b = computed(function()
|
||||
if a() == 1 then
|
||||
error("fail")
|
||||
end
|
||||
|
|
@ -472,9 +474,11 @@ test.describe("error handling", function()
|
|||
assert(c() == 0, "c should be 0")
|
||||
|
||||
a(1)
|
||||
local success, result = pcall(b)
|
||||
local success, result = pcall(function()
|
||||
b()
|
||||
end)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
assert(string.match(result, "fail"), "should throw fail")
|
||||
|
||||
a(2)
|
||||
assert(c() == 2, "c should recover and return 2")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ local test = require("../../../tests/test")
|
|||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local listen = Charm.listen
|
||||
local signal = Charm.signal
|
||||
local subscribe = Charm.subscribe
|
||||
local trigger = Charm.trigger
|
||||
|
|
@ -68,7 +67,7 @@ test("should trigger effect once", function()
|
|||
end)
|
||||
|
||||
test("should not notify the trigger function sub", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src1 = signal({})
|
||||
local src2 = computed(function()
|
||||
return src1()
|
||||
end)
|
||||
|
|
@ -91,21 +90,9 @@ test("should trigger updates for subscribe", function()
|
|||
triggers += 1
|
||||
end)
|
||||
|
||||
table.insert(src(), 1)
|
||||
trigger(src)
|
||||
assert(triggers == 1, "subscribe callback should run after trigger")
|
||||
end)
|
||||
|
||||
test("should trigger updates for listen", function()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
listen(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "listen callback should run once initially")
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 2, "listen callback should run after trigger")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ end)
|
|||
|
||||
test("should run detached from effect scope", function()
|
||||
local effects = 0
|
||||
local stopEffect: () -> ()
|
||||
local stopEffect
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
untracked(function()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "littensy/charm"
|
||||
version = "0.11.1"
|
||||
version = "0.11.0-rc.5"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@rbxts/deep-charm",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0-rc.1",
|
||||
"description": "Use Charm with plain Luau tables",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ end
|
|||
]]
|
||||
local function toRaw<T>(value: T): T
|
||||
if isReactive(value) then
|
||||
local proxy = value :: ReactiveProxy<T>
|
||||
proxy.__track()
|
||||
return toRaw(proxy.__target)
|
||||
local value = value :: ReactiveProxy<T> & T
|
||||
value.__track()
|
||||
return toRaw(value.__target)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
|
@ -115,7 +115,7 @@ end
|
|||
@return A reactive proxy if the value is a table
|
||||
]]
|
||||
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
|
||||
return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T
|
||||
return if type(value) == "table" then reactive(value, trackParent) else value
|
||||
end
|
||||
|
||||
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ local Charm = require("../Charm")
|
|||
local DeepCharm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local batch = Charm.batch
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local observe = Charm.observe
|
||||
local reactive = DeepCharm.reactive
|
||||
local toRaw = DeepCharm.toRaw
|
||||
|
||||
|
|
@ -52,10 +49,10 @@ test("should unwrap proxy assignments", function()
|
|||
end)
|
||||
|
||||
src.a, src.b = src.a, src.b
|
||||
assert(runs == 1, `bad runs: {runs}`)
|
||||
assert(runs == 1, runs)
|
||||
|
||||
src.a, src.b = src.b, src.a
|
||||
assert(runs == 3, `bad runs: {runs}`)
|
||||
assert(runs == 3, runs)
|
||||
end)
|
||||
|
||||
test("should track iteration", function()
|
||||
|
|
@ -128,10 +125,10 @@ test("should not track unread properties", function()
|
|||
local _ = src.value.a
|
||||
calls += 1
|
||||
end)
|
||||
assert(calls == 1, `bad calls: {calls}`)
|
||||
assert(calls == 1, calls)
|
||||
|
||||
src.value.b = 3
|
||||
assert(calls == 1, `bad calls: {calls}`)
|
||||
assert(calls == 1, calls)
|
||||
end)
|
||||
|
||||
test("should overwrite with table passed to setter", function()
|
||||
|
|
@ -162,10 +159,10 @@ test("should unwrap proxy", function()
|
|||
|
||||
assert(toRaw(src) == raw, "unwrap did not return the original table")
|
||||
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
|
||||
assert(runs == 1, `bad runs: {runs}`)
|
||||
assert(runs == 1, runs)
|
||||
|
||||
src.x = 1
|
||||
assert(runs == 2, `bad runs: {runs}`)
|
||||
assert(runs == 2, runs)
|
||||
end)
|
||||
|
||||
test("should unwrap nested proxy", function()
|
||||
|
|
@ -180,53 +177,13 @@ test("should unwrap nested proxy", function()
|
|||
end)
|
||||
|
||||
assert(toRaw(src) == raw, "unwrap did not return the original table")
|
||||
assert(runs == 1, `bad runs: {runs}`)
|
||||
assert(runs == 1, runs)
|
||||
|
||||
inner.x = 1
|
||||
assert(runs == 2, `bad runs: {runs}`)
|
||||
assert(runs == 2, runs)
|
||||
|
||||
src.y = 2
|
||||
assert(runs == 3, `bad runs: {runs}`)
|
||||
end)
|
||||
|
||||
test("should observe items", function()
|
||||
local items, updateItems = reactive({ 0, 0, 0 })
|
||||
local updates = {}
|
||||
local cleanups = {}
|
||||
|
||||
observe(function()
|
||||
return toRaw(items)
|
||||
end, function(_, key: number)
|
||||
local get = computed(function()
|
||||
return items[key]
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(updates, get())
|
||||
end)
|
||||
|
||||
return function()
|
||||
table.insert(cleanups, key)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
updateItems(function(state)
|
||||
state[1] = 1
|
||||
state[2] = 2
|
||||
state[3] = 3
|
||||
end)
|
||||
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
batch(function()
|
||||
items[3] = nil
|
||||
items[4] = 4
|
||||
end)
|
||||
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
|
||||
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
|
||||
assert(runs == 3, runs)
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
[package]
|
||||
name = "littensy/deep-charm"
|
||||
version = "0.1.1"
|
||||
version = "0.1.0-rc.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0"
|
||||
Charm = "littensy/charm@^0.11.0-rc.5"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0-rc.3",
|
||||
"description": "React bindings for Charm state management",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ local React = require("./React")
|
|||
@param dependencies An array of values used to memoize the getter.
|
||||
@return The current state.
|
||||
]]
|
||||
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
|
||||
local value: T, setValue = React.useState(getter)
|
||||
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
|
||||
local value, setValue = React.useState(getter)
|
||||
|
||||
React.useLayoutEffect(function(): () -> ()
|
||||
return Charm.listen(getter, setValue :: (T, T?) -> ())
|
||||
end, dependencies :: { any } or {})
|
||||
React.useLayoutEffect(function()
|
||||
return Charm.listen(getter, setValue)
|
||||
end, dependencies or {})
|
||||
|
||||
return value
|
||||
end
|
||||
|
|
@ -34,13 +34,13 @@ end
|
|||
@param dependencies An array of values used to memoize the getter.
|
||||
@return A binding that stores the current state.
|
||||
]]
|
||||
local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
|
||||
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
|
||||
local initialValue = React.useMemo(getter, {})
|
||||
local binding, setBinding = React.useBinding(initialValue)
|
||||
|
||||
React.useLayoutEffect(function(): () -> ()
|
||||
return Charm.listen(getter, setBinding :: (T, T?) -> ())
|
||||
end, dependencies :: { any } or {})
|
||||
React.useLayoutEffect(function()
|
||||
return Charm.listen(getter, setBinding)
|
||||
end, dependencies or {})
|
||||
|
||||
return binding
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,13 +13,12 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
|
|||
|
||||
local function Component(props: Props)
|
||||
hook.result = callback(props)
|
||||
return nil
|
||||
end
|
||||
|
||||
function hook.rerender(newProps: Props?)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
root:render(React.createElement(Component, props) :: any)
|
||||
root:render(React.createElement(Component, props))
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "littensy/react-charm"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0-rc.3"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
|
|
@ -13,5 +13,5 @@ exclude = [
|
|||
]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0"
|
||||
Charm = "littensy/charm@^0.11.0-rc.5"
|
||||
React = "jsdotlua/react@^17"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@rbxts/vide-charm",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0-rc.3",
|
||||
"description": "Vide bindings for Charm state management",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ local Vide = require("./Vide")
|
|||
local function useSignalState<State>(getter: () -> State): () -> State
|
||||
local state = Vide.source(getter())
|
||||
|
||||
Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
|
||||
Vide.cleanup(Charm.listen(getter, state))
|
||||
|
||||
return state
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ local test = require("../../../tests/test")
|
|||
|
||||
test("tracks signal state", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local source: () -> number
|
||||
local source
|
||||
local cleanup = Vide.root(function()
|
||||
source = VideCharm.useSignalState(getState)
|
||||
end)
|
||||
|
|
@ -17,7 +17,7 @@ end)
|
|||
|
||||
test("accepts a selector", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local source: () -> number
|
||||
local source
|
||||
local cleanup = Vide.root(function()
|
||||
source = VideCharm.useSignalState(function()
|
||||
return 2 * getState()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "littensy/vide-charm"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0-rc.4"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
|
|
@ -13,5 +13,5 @@ exclude = [
|
|||
]
|
||||
|
||||
[dependencies]
|
||||
Charm = "littensy/charm@^0.11.0"
|
||||
Charm = "littensy/charm@^0.11.0-rc.5"
|
||||
Vide = "centau/vide@^0.4"
|
||||
|
|
|
|||
715
pnpm-lock.yaml
generated
715
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,2 @@
|
|||
packages:
|
||||
- packages/*
|
||||
publicHoistPattern:
|
||||
- "*@rbxts*"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
[tools]
|
||||
wally = "UpliftGames/wally@0.3.2"
|
||||
rojo = "rojo-rbx/rojo@7.7.0"
|
||||
stylua = "johnnymorganz/stylua@2.5.2"
|
||||
luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
|
||||
lute = "luau-lang/lute@1.0.0"
|
||||
rojo = "rojo-rbx/rojo@7.6.1"
|
||||
stylua = "johnnymorganz/stylua@2.3.1"
|
||||
selene = "Kampfkarren/selene@0.30.0"
|
||||
luau-lsp = "johnnymorganz/luau-lsp@1.62.0"
|
||||
lute = "luau-lang/lute@0.1.0-nightly.20260219"
|
||||
|
|
|
|||
7
selene.toml
Normal file
7
selene.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
std = "roblox"
|
||||
exclude = ["*/node_modules/*"]
|
||||
|
||||
[lints]
|
||||
global_usage = "allow"
|
||||
shadowing = "allow"
|
||||
incorrect_standard_library_use = "allow"
|
||||
1
tests/init.luau
Normal file
1
tests/init.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
(require)("@self/run.server")
|
||||
Loading…
Add table
Add a link
Reference in a new issue