Compare commits

..

13 commits

Author SHA1 Message Date
littensy
af4eb5262d chore: bump pnpm
Some checks failed
CI / analyze (push) Has been cancelled
2026-09-30 19:31:07 -07:00
littensy
1b856b065e release: charm v0.11.1 2026-09-30 19:30:33 -07:00
littensy
aa84fa1e3d fix(charm-sync): warn if attempting to sync non-registered signal on the client 2026-09-30 19:25:45 -07:00
littensy
2fc3be34d0 fix(charm-sync): preserve global patch list order for nil patches
Resolves #67
2026-09-30 19:21:06 -07:00
littensy
c31e5c5a34 fix(charm-sync): run addSignalsToClient outside tracking scope 2026-09-30 19:06:38 -07:00
littensy
73fbfb2685 fix: resolve type errors for new luau version
Some checks are pending
CI / analyze (push) Waiting to run
2026-09-30 18:53:07 -07:00
richard
b05f3a9de6
docs: add missing playerReadyEvent call 2026-06-21 18:54:46 -07:00
richard
fdb506dc23
docs: update readme with new charm-sync details 2026-06-21 18:40:03 -07:00
littensy
ba870a8c2c release: charm v0.11.0 2026-06-20 21:34:35 -07:00
littensy
29be66fa94 test: add skipped inner effect dispose order test 2026-06-20 21:16:14 -07:00
littensy
d09fb5536b fix: set strict flags if RunService.IsStudio is true 2026-06-20 21:07:50 -07:00
richard
573533058f
feat!: use alien-signals for reactivity (#50)
* feat: use alien-signals

* refactor: remove pnpm, fix tests

* fix: `effectScope` should set scope

* fix: check previous value before calling listener

* refactor: improve imports

* refactor: exclude re-exports from wally

* refactor: embrace scope cleanup, restructure code

* fix: update pkgs dir references

* ci: simplify pesde install

* build: install dev deps with wally

* feat: add detached, add more tests

* test: simplify mapped test update

* chore: rephrase doc comment

* perf: optimize subscribe and mapped

* refactor: avoid ambiguous optional function type

* refactor: add assertion error messages

* perf: optimize mapped, add benches

* chore: delete redundant benchmarks

* perf: simplify mapped logic

* test: prettify test library

* test: try different gray

* test: color brackets

* refactor: sync 2.0.4

* refactor: use setCurrentSub instead of pauseTracking

* test: improve test suite

* test: separate parent from root

* refactor: iterate over previous data in mapped

* refactor: improve logic

* style: use `---` doc comments

* refactor: improve logic

* chore: housekeeping

* ci: add pnpm step

* ci: init submodules

* test: use lune

* refactor: improve structure and alien-signals integration

* ci: update test file path

* feat: add peek and roblox-ts types

* feat: add deep freeze flag, misc refactors

* refactor: reduce type casting, housekeeping

* refactor: rewrite untracked, update alien-signals

* chore: update eslint, housekeeping

* refactor: start charm-sync rewrite

* fix: send init payload even if atom did not update

* test: add all sync tests

* feat: prepare for alpha release

* feat: add peek, make untracked escape scope

* fix: wrap hooks in untracked, update types

* feat: add flag for inner effect tracking

* refactor: add docs, remove some modules

* refactor: align with alien-signals 3.0

* feat: add signal api

* refactor: update charm sync api

* feat: update alien-signals

* refactor: remove unnecessary features, update docs

* feat: finalize sync docs

* docs: update sync section

* docs: simplify examples

* docs: capitalize headers

* docs: add migration tips

* docs: fix whitespace

* docs: rephrase computed arg change

* refactor: rename batch, misc fixes & perf updates

* refactor: simplify charm sync validation

* feat: update alien-signals, add recursion flag

* feat: add `recursive()`

* fix: allow dispose in observers

* feat: add `trigger()`, refactor source

* docs: update readme

* chore: prepare release candidate

* test: make some tests more reliable

* docs: fix typo

* feat: add `reactive()`

* chore: bump versions

* docs: reword reactive docs

* docs: fix typo

* feat: add `toRaw()`

* fix: check for nil diffs

* chore: bump versions

* perf: do not create proxies for iteration

* fix: make `toRaw` track all calls

* fix: check none before patching mutable tables

* chore: bump charm-sync version

* docs: add fishing game example project

* feat: sync signals that return reactive proxy

* fix: refine reactive proxy logic

* fix: prevent early reactive gc, alien-signals 3.1.1

* fix: wrap iterated values in `reactive()`

* fix: loosen client signal types

* chore: bump release candidate

* fix: remove unsynced signals immediately

* test: use lute for tests

* chore: bump versions

* ci: set up lute

* refactor: improve source readability

* ci: upgrade lute

* build: loosen workspace version requirements

* chore: bump deps

* fix: update signal overload for setter (#58)

* fix: add missing recursed check

* test: add important vue reactivity tests

* chore: fix react tests, run stylua

* chore: update vide

* docs: improve intro and explain getters

* docs: adjust ordering by importance

* docs: move listen nesting note

* build: fix react peer dep for ci

* chore: update lockfile

* ci: use javascript eslint config

* ci: ignore eslint config file

* refactor: move test directories

* feat: allow recursion in effects by default

* release: charm v0.11.0-rc.4

* refactor: remove recursive()

* refactor: reorganize repo, create deep-charm

* docs: rephrase some sections

* feat: export getter and setter types

* docs: rephrase intro again

* feat!: run listen and subscribe callback in untracked

* refactor!: remove peek

* docs: note peek in migration guide

* release: charm v0.11.0-rc.5

* release: charm-sync v0.4.0-rc.7

* release: react-charm v0.4.0-rc.4

* fix: remove old keys when updating key in `mapped()`

* refactor: shorten error traceback, housekeeping

* refactor: keep error level in some cases

* fix: check if cleanup functions disposed effect

* refactor!: run effects in batched

* ci: check eslint and prettier

* perf: run observe fn in untracked

* perf: call mapper as-is in `mapped()`

* refactor: error for effect cleanup errors

* refactor: use new type solver

* ci: disable new solver flag until 0.714

* chore: housekeeping

* refactor: improve errors in nonstrict, optimize noyield

* chore: housekeeping

* refactor: satisfy new solver

* refactor: improve error traceback

* docs: link roblox signals

* chore: housekeeping

* perf: remove `clearPending` inline operation

* refactor: check type of cleanup

* ci: remove selene

* fix: always return pending value in signal setter

* test: add multi benchmark

* release: charm v0.11.0-rc.6

* test: add cleanup order tests

* chore: bump deps

* docs: add warning for deep-charm and note for `observe()`

---------

Co-authored-by: safazi <safazics@gmail.com>
2026-06-20 20:51:17 -07:00
alihsaas
d0b7cff943
fix: add cleanup parameter to effect type (#52) 2025-07-23 00:35:39 -07:00
58 changed files with 1947 additions and 899 deletions

View file

@ -20,8 +20,13 @@ jobs:
- name: Check code quality - name: Check code quality
run: lute run check run: lute run check
- name: Run ESLint and Prettier
run: |
pnpm eslint packages
pnpm prettier --check packages
- name: Run tests - name: Run tests
run: lute run tests run: lute run test
- name: Build models - name: Build models
run: lute run build run: lute run build

15
.luaurc
View file

@ -1,10 +1,7 @@
{ {
"languageMode": "strict", "languageMode": "strict",
"aliases": { "aliases": {
"lute": "~/.lute/typedefs/0.1.0/lute", "lute": "~/.lute/typedefs/1.0.0/lute",
"std": "~/.lute/typedefs/0.1.0/std" "std": "~/.lute/typedefs/1.0.0/std"
}, }
"lint": { }
"LocalShadow": false
}
}

View file

@ -1,6 +1,6 @@
local fs = require("@std/fs") local fs = require("@std/fs")
local process = require("@lute/process") local process = require("@lute/process")
fs.createdirectory("build", { makeparents = true }) fs.createDirectory("build")
process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" }) process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" })

View file

@ -1,10 +1,10 @@
local fs = require("@std/fs") local fs = require("@std/fs")
local net = require("@lute/net") local net = require("@std/net")
local process = require("@lute/process") local process = require("@lute/process")
process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })
fs.writestringtofile( fs.writeStringToFile(
"roblox.d.luau", "roblox.d.luau",
net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
) )
@ -16,6 +16,7 @@ local analyze = process.run({
"--ignore=**/node_modules/**", "--ignore=**/node_modules/**",
"--base-luaurc=.luaurc", "--base-luaurc=.luaurc",
"--definitions=roblox.d.luau", "--definitions=roblox.d.luau",
"--flag:LuauSolverV2=true",
"packages", "packages",
"tests", "tests",
"benches", "benches",
@ -23,12 +24,10 @@ local analyze = process.run({
fs.remove("roblox.d.luau") 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 stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" })
local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" }) local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" })
if not (analyze.ok and selene.ok and stylua.ok and eslint.ok) then if not (analyze.ok and stylua.ok and eslint.ok) then
process.exit(1) process.exit(1)
end end

1
.lute/test.luau Normal file
View file

@ -0,0 +1 @@
(require)("../tests/run.server")

1
.npmrc
View file

@ -1 +0,0 @@
public-hoist-pattern[]=*@rbxts*

203
README.md
View file

@ -18,20 +18,20 @@
</div> </div>
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. 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.
**Build game state from simple building blocks:** **Build game state from simple building blocks:**
- Store state in [signals](#signalinitialvalue-equals): state containers that hold a value - Store data in [signals](#signalinitialvalue-equals): reactive state containers that hold a value
- React to state changes with [effects](#effectcallback): re-run code when a dependency updates - React to state changes with [effects](#effectcallback): re-run code when signals update
- Derive values from state with [computed signals](#computedgetter): memoized functions with dependency tracking - Derive new values from data with [computed signals](#computedgetter): memoize functions that access signals
**Want to learn more about signals?** **Want to learn more about reactivity?**
- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf - https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf
- https://preactjs.com/blog/introducing-signals - https://preactjs.com/blog/introducing-signals
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity - https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
- https://angular.dev/guide/signals - https://github.com/roblox/signals
[Migrating from an older version of Charm?](#migration) [Migrating from an older version of Charm?](#migration)
@ -51,7 +51,7 @@ Charm is a state management library based on [fine-grained reactivity](https://d
- [`subscribe(getter, callback)`](#subscribegetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback)
- [`untracked(callback)`](#untrackedcallback) - [`untracked(callback)`](#untrackedcallback)
- [`batch(callback)`](#batchcallback) - [`batch(callback)`](#batchcallback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper) - [`mapped(getter, transform)`](#mappedgetter-transform)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently) - [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals) - [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`trigger(callback)`](#triggercallback) - [`trigger(callback)`](#triggercallback)
@ -472,11 +472,11 @@ end)
--- ---
### `mapped(getter, mapper)` ### `mapped(getter, transform)`
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 `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 first value returned by the mapper is used as the new value: The first value returned by the transform function is used as the new value:
```luau ```luau
local getList, setList = signal({ "a", "b", "c" }) local getList, setList = signal({ "a", "b", "c" })
@ -488,7 +488,7 @@ end)
print(getUppercase()) -- { "A", "B", "C" } print(getUppercase()) -- { "A", "B", "C" }
``` ```
If the mapper returns two values, the second value is used as the new key: If the transform function returns two values, the second value is used as the new key:
```luau ```luau
local getList, setList = signal({ "a", "b", "c" }) 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. | | 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. | | 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 and other development environments where the Luau optimization level is `O1` or lower. The `strict` and `frozen` flags are automatically enabled in Roblox Studio.
--- ---
@ -623,72 +623,99 @@ CharmSync = "littensy/charm-sync@VERSION"
### Quick Start ### Quick Start
Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory:
```luau ```luau
-- nameStore -- store.luau
local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe") local getStatus, setStatus = signal("intermission")
local ageAtom = atom(20) local timerAtom = atom(20)
local getInventories, setInventories = signal({} :: { [Username]: Inventory })
return { return {
getName = getName, getStatus = getStatus,
setName = setName, setStatus = setStatus,
getSurname = getSurname, timerAtom = timerAtom,
setSurname = setSurname, getInventories = getInventories,
ageAtom = ageAtom, setInventories = setInventories,
} }
``` ```
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. #### Server setup
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. When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to:
```luau ```luau
local function onPlayerAdded(player: Player) -- sync.server.luau
-- Add signal getters, computed signals, atoms, or reactive objects
playerReadyEvent.OnServerEvent:Connect(function(player)
-- Sync signal getters, computed signals, atoms, or reactive proxies
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
name = nameStore.getName, status = store.getStatus,
surname = nameStore.getSurname, timer = store.timerAtom,
age = nameStore.ageAtom, -- Signals unique to a player should also have a key unique to
-- that player, to avoid overwriting other synced signals
[`inventory-{player.UserId}`] = computed(function()
return store.getInventories()[player.Name]
end),
}) })
end end)
for _, player in Players:GetPlayers() do
onPlayerAdded(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(function(player) Players.PlayerRemoving:Connect(function(player)
server.removeClient(player) server.removeClient(player)
end) end)
server.connect(function(player, updates) server.connect(function(player, updates)
-- Customize how you send state updates to clients -- Customize how you replicate state updates to clients
syncEvent:FireClient(player, updates) syncEvent:FireClient(player, updates)
end) end)
-- Changes now get replicated to clients
store.setStatus("started")
store.timerAtom(30)
``` ```
> [!NOTE] > [!WARNING]
> 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. > 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
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. 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. After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
```luau ```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 -- Add signal setters, atoms, or reactive proxies
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
age = nameStore.ageAtom, -- Signals unique to a player should also have a key unique to
-- that player, to avoid name collisions
[`inventory-{Players.LocalPlayer.UserId}`] = setInventory,
}) })
-- Update client signals when receiving updates from the server -- Update client signals when receiving updates from the server
syncEvent.OnClientEvent:Connect(function(updates) syncEvent.OnClientEvent:Connect(function(updates)
client.patch(updates) client.patch(updates)
end) 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)
``` ```
--- ---
@ -701,37 +728,49 @@ 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. | | 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. | | preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. |
| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). | | 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 throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). | | 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). |
--- ---
### `server.addSignalsToClient(client, getters)` ### `server.addSignalsToClient(client, getters)`
The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. 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.
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. 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.
```luau ```luau
Players.PlayerAdded:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
name = nameStore.getName, status = store.getStatus,
surname = nameStore.getSurname, timer = store.timerAtom,
age = nameStore.ageAtom,
}) })
end) end)
``` ```
You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player: You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal:
```luau ```luau
server.addSignalsToClient(player, { playerReadyEvent.OnServerEvent:Connect(function(player)
[`data-{player.UserId}`] = computed(function() server.addSignalsToClient(player, {
return getPlayerData(player.UserId) -- 🟢 Good: Player-specific signals are synced with a unique key
end), [`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)
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)` ### `server.removeSignalsFromClient(client, ...keys)`
@ -740,11 +779,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke
```luau ```luau
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
name = nameStore.getName, status = store.getStatus,
surname = nameStore.getSurname, timer = store.timerAtom,
}) })
server.removeSignalsFromClient(player, "surname") server.removeSignalsFromClient(player, "status")
``` ```
--- ---
@ -754,7 +793,7 @@ server.removeSignalsFromClient(player, "surname")
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. 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 ```luau
Players.PlayerAdded:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, signals) server.addSignalsToClient(player, signals)
end) end)
@ -805,9 +844,11 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit
```luau ```luau
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
age = nameStore.ageAtom, -- Signals unique to a player should also have a key unique to
-- that player, to avoid name collisions
[`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
}) })
``` ```
@ -819,11 +860,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta
```luau ```luau
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
}) })
client.removeSignals("name") client.removeSignals("status")
``` ```
--- ---
@ -836,8 +877,9 @@ You should call `patch` when receiving updates from the server from a remote eve
```luau ```luau
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
[`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
}) })
syncEvent.OnClientEvent:Connect(function(updates) syncEvent.OnClientEvent:Connect(function(updates)
@ -853,8 +895,8 @@ If you have a lot of signals to sync between the server and clients, it might be
```luau ```luau
local sharedState = { local sharedState = {
name = signalToAtom(nameStore.getName, nameStore.setName), status = signalToAtom(store.getStatus, store.setStatus),
surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), inventories = signalToAtom(store.getInventories, store.setInventories),
} }
client.addSignals(sharedState) client.addSignals(sharedState)
@ -867,7 +909,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`. Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`.
But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`.
This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. 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.
@ -883,6 +925,9 @@ You can opt-in to deep reactivity with the Deep Charm library:
### Installation ### Installation
> [!WARNING]
> Deep Charm is an experimental library that is not fully documented, and the API is subject to breaking changes.
```sh ```sh
npm install @rbxts/deep-charm npm install @rbxts/deep-charm
yarn add @rbxts/deep-charm yarn add @rbxts/deep-charm
@ -938,6 +983,22 @@ local proxy = reactive(raw)
print(toRaw(proxy) == raw) -- true 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)` ### `isReactive(value)`

View file

@ -20,8 +20,8 @@ local function run(name: string, test: () -> ())
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
for name, elapsed in times do for name, duration in times do
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`) table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`)
end end
print(table.concat(results, " | ")) print(table.concat(results, " | "))

View file

@ -3,55 +3,56 @@ local bench = require("./bench")
local computed = Charm.computed local computed = Charm.computed
local mapped = Charm.mapped local mapped = Charm.mapped
local setActiveSub = Charm.setActiveSub
local signal = Charm.signal local signal = Charm.signal
type Entry = { number } type Key = number
type Value = number
local count = 512 local count = 512
local initialState: { Entry } = table.create(count) local initialState: { [Key]: Value } = table.create(count, 0)
for i = 1, count do local function expensiveOperation()
initialState[i] = table.create(64, i) for _ = 1, 100 do
continue
end
end end
local function mapper(value: Entry, key: number): (Entry, number) local function simpleMapped(
return table.clone(value), key + 1 source: () -> { [Key]: Value },
end mapper: (Value, Key) -> (Value, Key?)
): () -> { [Key]: Value }
local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
return computed(function() return computed(function()
local state = source() local state = source()
local nextState = {} local nextState = {}
for index, value in state do for keyIn, valueIn in state do
local currentSub = setActiveSub(nil) local valueOut, keyOut = mapper(valueIn, keyIn)
local success, valueOut, keyOut = pcall(mapper, value, index) nextState[if keyOut == nil then keyIn else keyOut] = valueOut
setActiveSub(currentSub)
if success then
nextState[keyOut] = valueOut
end
end end
return state return nextState
end) end)
end end
local function updatePercentage(percent: number): ({ Entry }) -> { Entry } local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
return function(state) return function(state)
local nextState = table.clone(state) local nextState = table.clone(state)
for index, value in state do for key in state do
if math.random() < percent then if math.random() < updateChance then
nextState[index] = table.clone(value) nextState[key] += 1
end end
end end
return nextState return nextState
end end
end end
for _, percent in { 0.1, 0.5, 1 } do for _, updateChance in { 0.1, 0.5, 1 } do
bench.run(`mapped {percent}`, function() local function mapper(value: Value, key: Key): (Value, Key?)
expensiveOperation()
return value + 1, key + 1
end
bench.run(`mapped {updateChance}`, function()
local src, setSrc = signal(initialState) local src, setSrc = signal(initialState)
local out = mapped(src, mapper) local out = mapped(src, mapper)
@ -59,14 +60,14 @@ for _, percent in { 0.1, 0.5, 1 } do
out() out()
bench.profileend() bench.profileend()
setSrc(updatePercentage(percent)) setSrc(updateRandomly(updateChance))
bench.profilebegin("update") bench.profilebegin("update")
out() out()
bench.profileend() bench.profileend()
end) end)
bench.run(`control {percent}`, function() bench.run(`control {updateChance}`, function()
local src, setSrc = signal(initialState) local src, setSrc = signal(initialState)
local out = simpleMapped(src, mapper) local out = simpleMapped(src, mapper)
@ -74,7 +75,7 @@ for _, percent in { 0.1, 0.5, 1 } do
out() out()
bench.profileend() bench.profileend()
setSrc(updatePercentage(percent)) setSrc(updateRandomly(updateChance))
bench.profilebegin("update") bench.profilebegin("update")
out() out()

188
benches/multi.luau Normal file
View file

@ -0,0 +1,188 @@
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

View file

@ -1,29 +1,29 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"test": "lute run tests", "test": "lute run test",
"build": "lute run build", "build": "lute run build",
"check": "lute run check", "check": "lute run check",
"publish:npm": "pnpm publish -r --access public", "publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
}, },
"packageManager": "pnpm@10.30.2", "packageManager": "pnpm@12.8.1",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.4",
"@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/compiler-types": "3.0.0-types.0",
"@rbxts/react": "17.3.7-ts.1", "@rbxts/react": "17.3.7-ts.1",
"@rbxts/react-roblox": "17.3.7-ts.1", "@rbxts/react-roblox": "17.3.7-ts.1",
"@rbxts/types": "^1.0.908", "@rbxts/types": "^1.0.913",
"@rbxts/vide": "^0.6.0", "@rbxts/vide": "^0.6.1",
"@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/parser": "^8.58.0",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-roblox-ts": "^1.3.1", "eslint-plugin-roblox-ts": "^1.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"roblox-ts": "^3.0.0", "roblox-ts": "^3.0.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.0" "typescript-eslint": "^8.58.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm-sync", "name": "@rbxts/charm-sync",
"version": "0.4.0-rc.6", "version": "0.4.1",
"description": "Simple server-to-client sync for Charm", "description": "Simple server-to-client sync for Charm",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -3,7 +3,7 @@ local patch = require("./patch")
local types = require("./types") local types = require("./types")
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ReactiveProxy<T = {}> = types.ReactiveProxy<T> type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
local trackedSignals: { [string]: (any) -> any } = {} local trackedSignals: { [string]: (any) -> any } = {}
local trackedReactives: { [string]: ReactiveProxy } = {} local trackedReactives: { [string]: ReactiveProxy } = {}
@ -64,6 +64,8 @@ local function applyInitPayload(data: { [string]: any })
trackedSignals[key](value) trackedSignals[key](value)
elseif trackedReactives[key] then elseif trackedReactives[key] then
shallowMergeReactive(trackedReactives[key], value) shallowMergeReactive(trackedReactives[key], value)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end end
end end
end end
@ -80,6 +82,8 @@ local function applyPatchPayload(data: { [string]: any })
elseif trackedReactive then elseif trackedReactive then
patch.applyMutable(trackedReactive.__target, diffs) patch.applyMutable(trackedReactive.__target, diffs)
Charm.trigger(trackedReactive.__track) Charm.trigger(trackedReactive.__track)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end end
end end
end end
@ -90,7 +94,7 @@ end
@param payloads The state updates received from the server. @param payloads The state updates received from the server.
]] ]]
local function patch(payloads: { SyncPayload }) local function applyPatches(payloads: { SyncPayload })
for _, payload in payloads do for _, payload in payloads do
if payload.type == "init" then if payload.type == "init" then
Charm.batch(applyInitPayload, payload.data) Charm.batch(applyInitPayload, payload.data)
@ -104,5 +108,5 @@ return {
addSignals = addSignals, addSignals = addSignals,
removeSignals = removeSignals, removeSignals = removeSignals,
removeAllSignals = removeAllSignals, removeAllSignals = removeAllSignals,
patch = patch, patch = applyPatches,
} }

View file

@ -9,19 +9,22 @@ return {
Whether the history of state changes since the client's last update Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple should be preserved. This is useful for values that change multiple
times per frame, where each individual change is important. Defaults times per frame, where each individual change is important. Defaults
to `false`. to `false` for performance reasons.
]] ]]
preserveHistory = false, preserveHistory = false,
--[[ --[[
When `true`, Charm will apply validation and serialize unsafe arrays When `true`, Charm will modify sparse arrays to prevent data loss from
to address remote event argument limitations. Defaults to `true`. remote event limitations. When an unsafe array is detected, numeric
indices will be converted to string keys so that the array can be sent
safely and then converted back to an array on the receiving end.
This option should be disabled if your network library uses a custom This option should be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet) to prevent interference. serialization method (i.e. Zap, ByteNet) to prevent interference.
]] ]]
fixArrays = true, fixArrays = true,
--[[ --[[
When `true`, synced state containing unsafe sparse arrays or mixed When `true`, synced state containing unsafe sparse arrays or mixed
tables will throw an error. Only checked in strict mode and if tables will emit a warning. Only checked in strict mode and if
`fixArrays` is enabled. Defaults to `true`. `fixArrays` is enabled. Defaults to `true`.
]] ]]
validatePatches = true, validatePatches = true,

View file

@ -78,14 +78,17 @@ export const config: {
* Whether the history of state changes since the client's last update * Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple * should be preserved. This is useful for values that change multiple
* times per frame, where each individual change is important. Defaults * times per frame, where each individual change is important. Defaults
* to `false`. * to `false` for performance reasons.
* *
* @server * @server
*/ */
preserveHistory: boolean; preserveHistory: boolean;
/** /**
* When `true`, Charm will apply validation and serialize unsafe arrays * When `true`, Charm will modify sparse arrays to prevent data loss from
* to address remote event argument limitations. Defaults to `true`. * remote event limitations. When an unsafe array is detected, numeric
* indices will be converted to string keys so that the array can be sent
* safely and then converted back to an array on the receiving end.
*
* This option should be disabled if your network library uses a custom * This option should be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet) to prevent interference. * serialization method (i.e. Zap, ByteNet) to prevent interference.
* *
@ -94,7 +97,7 @@ export const config: {
fixArrays: boolean; fixArrays: boolean;
/** /**
* When `true`, synced state containing unsafe sparse arrays or mixed * When `true`, synced state containing unsafe sparse arrays or mixed
* tables will throw an error. Only checked in strict mode and if * tables will emit a warning. Only checked in strict mode and if
* `fixArrays` is enabled. Defaults to `true`. * `fixArrays` is enabled. Defaults to `true`.
* *
* @server * @server
@ -242,7 +245,7 @@ export namespace patch {
* @param statePatch The patches to apply. * @param statePatch The patches to apply.
* @returns The new state with the patch applied. * @returns The new state with the patch applied.
*/ */
export function apply<State, FixArrays extends boolean = true>( export function applyImmutable<State, FixArrays extends boolean = true>(
currentState: State, currentState: State,
statePatch: SyncPatch<State, FixArrays>, statePatch: SyncPatch<State, FixArrays>,
): State; ): State;

View file

@ -39,7 +39,7 @@ end
long as the target array is not sparse. This function ensures that the diff long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied. function produces a patch that can be correctly applied.
]] ]]
local function stringifySparseArray(object: {}): { [string | number]: any } local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
local maxn = table.maxn(object) local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then if maxn == 0 or maxn == #object then

View file

@ -39,9 +39,10 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
support custom implementations of reactive proxies. support custom implementations of reactive proxies.
]] ]]
local function toRaw<T>(value: T): T local function toRaw<T>(value: T): T
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then local proxy = value :: types.ReactiveProxy<T>
value.__track() if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
return toRaw(value.__target) proxy.__track()
return toRaw(proxy.__target)
end end
return value return value
end end
@ -60,7 +61,7 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
local rawInitialValue = toRaw(initialValue) local rawInitialValue = toRaw(initialValue)
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
local dispose = Charm.effect(function() local dispose = Charm.untracked(Charm.effect, function()
local newValue = getter() local newValue = getter()
local rawValue = toRaw(newValue) local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue local isReactive = newValue ~= rawValue
@ -124,8 +125,8 @@ local function diffGlobalUpdateBuffer(): { any }
local patchCount = #globalUpdates local patchCount = #globalUpdates
local patches = table.create(patchCount) local patches = table.create(patchCount)
for _, update in globalUpdates do for index, update in globalUpdates do
table.insert(patches, patch.diff(update.oldValue, update.newValue)) patches[index] = patch.diff(update.oldValue, update.newValue)
end end
return patches return patches
@ -145,7 +146,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
for index, update in globalUpdates do for index, update in globalUpdates do
local patchData = globalPatches[index] local patchData = globalPatches[index]
if not patchData then if patchData == nil then
continue continue
end end
@ -153,15 +154,17 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local status = clientKeys[key] local status = clientKeys[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
table.insert(payloads, { local payload: SyncPayload = {
type = "patch", type = "patch",
data = { [key] = patchData }, data = { [key] = patchData },
}) }
table.insert(payloads, payload)
elseif status == PENDING_INITIAL_STATE then elseif status == PENDING_INITIAL_STATE then
table.insert(payloads, { local payload: SyncPayload = {
type = "init", type = "init",
data = { [key] = update.newValue }, data = { [key] = update.newValue },
}) }
table.insert(payloads, payload)
clientKeys[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
@ -228,11 +231,19 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
end end
if next(initData) ~= nil then if next(initData) ~= nil then
table.insert(payloads, { type = "init", data = initData }) local payload: SyncPayload = {
type = "init",
data = initData,
}
table.insert(payloads, payload)
end end
if next(patchData) ~= nil then if next(patchData) ~= nil then
table.insert(payloads, { type = "patch", data = patchData }) local payload: SyncPayload = {
type = "patch",
data = patchData,
}
table.insert(payloads, payload)
end end
return payloads return payloads

View file

@ -2,10 +2,6 @@ local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T> type Atom<T> = Charm.Atom<T>
type NestedTable = {
[any]: NestedTable | any,
}
--[[ --[[
Flattens a nested table into a single table with slash-separated keys. Flattens a nested table into a single table with slash-separated keys.
Useful for recursively collecting atoms returned by modules. Useful for recursively collecting atoms returned by modules.
@ -13,15 +9,15 @@ type NestedTable = {
@param input A nested table. @param input A nested table.
@return A flattened table with slash-separated keys. @return A flattened table with slash-separated keys.
]] ]]
local function flatten<Flattened>(input: {}): Flattened local function flatten<Flattened>(input: { [any]: any }): Flattened
local result = {} local result = {}
local function visit(node: {}, path: string) local function visit(node: { [any]: any }, path: string)
for key, value in node do for key, value in node do
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key) local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
if type(value) == "table" then if type(value) == "table" then
visit(value :: {}, location) visit(value, location)
else else
result[location] = value result[location] = value
end end

View file

@ -1,11 +1,11 @@
local function isTableUnsafe(object: {}) local function isTableUnsafe(object: { [any]: any })
local key = next(object) local firstKey = next(object)
if key == nil then if firstKey == nil then
return false return false
end end
local keyType = type(key) local keyType = type(firstKey)
local maxKey = 0 local maxKey = 0
-- Keys must all be the same type -- Keys must all be the same type
@ -31,13 +31,11 @@ end
with remote argument limitations, such as sparse arrays or mixed tables. with remote argument limitations, such as sparse arrays or mixed tables.
@param value The table value. @param value The table value.
@param key The key of the value.
]] ]]
local function validate(value: {}) local function validate(value: { [any]: any })
if isTableUnsafe(value) then if isTableUnsafe(value) then
warn( warn(
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n` "Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
.. "This can be for the following reasons:\n"
.. "1. The table is a sparse array with non-sequential indices\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" .. "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", .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",

View file

@ -2,7 +2,7 @@ local config = require("../src/config")
local patch = require("../src/patch") local patch = require("../src/patch")
local test = require("../../../tests/test") local test = require("../../../tests/test")
local function mockArraySerdes(object: {}): {} local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then if object[1] then
local copy = {} local copy = {}
for index = 1, #object do for index = 1, #object do

View file

@ -4,6 +4,7 @@ local config = require("../src/config")
local patch = require("../src/patch") local patch = require("../src/patch")
local server = require("../src/server") local server = require("../src/server")
local test = require("../../../tests/test") local test = require("../../../tests/test")
local types = require("../src/types")
test.afterEach(function() test.afterEach(function()
server.reset() server.reset()
@ -14,7 +15,7 @@ test("should replicate state changes", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
@ -41,7 +42,7 @@ test("should replicate partial state patches", function()
local client = {} local client = {}
local a = Charm.atom({ x = 0, y = 0 }) local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?) local b = Charm.atom(0 :: number?)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b }) server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...) server.connect(function(...)
@ -68,7 +69,7 @@ end)
test("should replicate partial state patches (reactive)", function() test("should replicate partial state patches (reactive)", function()
local client = {} local client = {}
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? }) local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(...) server.connect(function(...)
@ -118,7 +119,7 @@ test("should init new clients", function()
local clientA = {} local clientA = {}
local clientB = {} local clientB = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads) server.connect(function(target, payloads)
@ -147,7 +148,7 @@ test("should init new atoms", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(...) server.connect(function(...)
@ -172,7 +173,7 @@ test("should only replicate to synced clients", function()
local clientB = {} local clientB = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local payloadsA, payloadsB local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b }) server.addSignalsToClient(clientB, { b = b })
@ -214,7 +215,7 @@ end)
test("should serialize state", function() test("should serialize state", function()
local client = {} local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 }) local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)
@ -231,7 +232,7 @@ end)
test("should not send unchanged state", function() test("should not send unchanged state", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)
@ -262,7 +263,7 @@ test.describe("historical", function()
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local c = Charm.atom(0) local c = Charm.atom(0)
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
@ -292,7 +293,7 @@ test.describe("historical", function()
local a = DeepCharm.reactive({ value = 0 }) local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 }) local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 }) local c = DeepCharm.reactive({ value = 0 })
local target, payloads local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c }) server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...) server.connect(function(...)
@ -321,7 +322,7 @@ test.describe("historical", function()
local clientA = {} local clientA = {}
local clientB = {} local clientB = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloadsA, payloadsB local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a }) server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads) server.connect(function(target, payloads)
@ -357,7 +358,7 @@ test.describe("historical", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local b = Charm.atom(0) local b = Charm.atom(0)
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...) server.connect(function(_, ...)
@ -386,7 +387,7 @@ test.describe("historical", function()
test("should not send unchanged state", function() test("should not send unchanged state", function()
local client = {} local client = {}
local a = Charm.atom(0) local a = Charm.atom(0)
local payloads local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a }) server.addSignalsToClient(client, { a = a })
server.connect(function(_, p) server.connect(function(_, p)
@ -402,6 +403,54 @@ test.describe("historical", function()
server.flush() server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end) 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) end)
return {} return {}

View file

@ -6,9 +6,10 @@ test("should flatten functions", function()
local input = { local input = {
a = Charm.atom(0), a = Charm.atom(0),
b = { c = Charm.atom(0) }, b = { c = Charm.atom(0) },
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, d = { e = { f = utils.signalToAtom<<number>>(Charm.signal(0)) } },
} }
local result = utils.flatten(input)
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
assert(result.a == input.a, "should flatten the first level") assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level") assert(result["b/c"] == input.b.c, "should flatten the second level")

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/charm-sync" name = "littensy/charm-sync"
version = "0.4.0-rc.6" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [
@ -12,4 +12,4 @@ exclude = [
] ]
[dependencies] [dependencies]
Charm = "littensy/charm@^0.11.0-rc.5" Charm = "littensy/charm@^0.11.0"

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm", "name": "@rbxts/charm",
"version": "0.11.0-rc.5", "version": "0.11.1",
"description": "Atomic state management based on alien-signals", "description": "Atomic state management based on alien-signals",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -50,12 +50,12 @@ export const flags: {
/** /**
* Enforces synchronous, non-yielding behavior in signals and effects. * Enforces synchronous, non-yielding behavior in signals and effects.
* Also enables state validation in Charm Sync to catch sync errors early. * Also enables state validation in Charm Sync to catch sync errors early.
* Enabled if the optimization level is below 2, which is true in studio. * Enabled by default in Roblox Studio.
*/ */
strict: boolean; strict: boolean;
/** /**
* Enforces immutability of tables by deep-freezing table values. Enabled * Enforces immutability of tables by deep-freezing table values. Enabled
* if the optimization level is below 2, which is true in studio. * by default in Roblox Studio.
*/ */
frozen: boolean; frozen: boolean;
/** /**
@ -179,53 +179,50 @@ export function batch<Args extends any[], Result>(fn: (...args: Args) => Result,
* when the value returned by the getter changes. The callback receives the new * when the value returned by the getter changes. The callback receives the new
* value and the previous value as arguments. * 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 getter A function that returns the value to watch.
* @param fn The function to run when the value changes. * @param callback The function to run when the value changes.
* @returns A function for disposing the effect. * @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/ */
export function listen<T>(getter: Getter<T>, fn: (value: T, previousValue: T | undefined) => void): Cleanup; export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
/** /**
* Creates an effect that only runs the callback when the value returned by the * 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 * getter changes. The callback receives the new value and the previous value
* as arguments. * 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 getter A function that returns the value to watch.
* @param fn The function to run when the value changes. * @param callback The function to run when the value changes.
* @returns A function for disposing the effect. * @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
*/ */
export function subscribe<T>(getter: Getter<T>, fn: (value: T, previousValue: T) => void): Cleanup; export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
/** /**
* Creates a new read-only signal that is computed by mapping over the entries * Creates a new read-only signal that is computed by mapping over the entries
* of an existing atom. If the key is omitted from the mapper's return value, * of the source table. If the key is omitted from the transform function's
* the original key is preserved. * return value, the original key is preserved.
* *
* @param getter Returns the table to map over. * This function is optimized to minimize unnecessary updates by only calling
* @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. * the transform function for entries that have changed since the last run.
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper *
* @param getter A signal or function that returns a table
* @param transform Function that maps each entry from the source to a new entry in the mapped table
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
*/ */
export function mapped<VI, KI, VO, KO = KI>( export function mapped<VI, KI, VO, KO = KI>(
getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>, getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): Getter<ReadonlyMap<KO, VO>>; ): Getter<ReadonlyMap<KO, VO>>;
// Overload for arrays // Overload for arrays
export function mapped<VI, VO, K extends Key = number>( export function mapped<VI, VO, K extends Key = number>(
getter: Getter<readonly VI[]>, getter: Getter<readonly VI[]>,
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>; ): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
// Overload for objects // Overload for objects
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>( export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
getter: Getter<{ readonly [K in KI]: VI }>, getter: Getter<{ readonly [K in KI]: VI }>,
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO, transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): Getter<{ readonly [K in KO]: VO }>; ): Getter<{ readonly [K in KO]: VO }>;
/** /**
@ -240,18 +237,18 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
* from the table or when the callback is disposed. * from the table or when the callback is disposed.
* *
* @param getter A function that returns the table to observe. * @param getter A function that returns the table to observe.
* @param fn A function that is called for each key in the table. * @param callback A function that is called for each key in the table.
* @returns A function for disposing the observer. * @returns A function for disposing the observer.
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
*/ */
export function observe<V, K = number>( export function observe<V, K = number>(
getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>, getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
fn: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): Cleanup;
// Overload for objects // Overload for objects
export function observe<V, K extends Key>( export function observe<V, K extends Key>(
getter: Getter<{ readonly [K in Key]: V }>, getter: Getter<{ readonly [K in Key]: V }>,
fn: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): Cleanup;
/** /**

File diff suppressed because it is too large Load diff

View file

@ -102,7 +102,7 @@ local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
end end
local function unlink(link: Link, sub: ReactiveNode?): Link? local function unlink(link: Link, sub: ReactiveNode?): Link?
local sub = sub or link.sub sub = sub or link.sub
local dep = link.dep local dep = link.dep
local prevDep = link.prevDep local prevDep = link.prevDep
local nextDep = link.nextDep local nextDep = link.nextDep
@ -149,7 +149,6 @@ local function propagate(link: Link)
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
flags = bit32.band(flags, MUTABLE) flags = bit32.band(flags, MUTABLE)
else else
-- selene: allow(if_same_then_else)
flags = NONE flags = NONE
end end

View file

@ -84,7 +84,7 @@ end)
test("should receive previous value", function() test("should receive previous value", function()
local calls = {} local calls = {}
local src, setSrc = signal(0) local src, setSrc = signal(0)
local c1 = computed(function(current): number local c1 = computed(function(current: number?): number
local incoming = src() local incoming = src()
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
end) end)
@ -192,10 +192,29 @@ test("should trigger effect even computed already dirty", function()
table.insert(calls, c2()) table.insert(calls, c2())
end) end)
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ",")) assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
assert(v() == 1, v()) assert(v() == 1, `expected 1 for v(), got {v()}`)
v(2) v(2)
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ",")) assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
assert(v() == 2, v()) 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)
end) end)
return {} return {}

View file

@ -239,8 +239,11 @@ test("should handle side effect with inner effects", function()
table.clear(order) table.clear(order)
setB(1) setB(1)
setA(1) setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end) 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() test("should handle flags are indirectly updated during checkDirty", function()
@ -268,7 +271,48 @@ test("should handle flags are indirectly updated during checkDirty", function()
assert(triggers == 2, "did not update indirectly") assert(triggers == 2, "did not update indirectly")
end) end)
test("should allow recursion", function() 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()
local src, setSrc = signal(0) local src, setSrc = signal(0)
local order = {} local order = {}
@ -364,15 +408,186 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 0, "should not run after b update") assert(calls == 0, "should not run after b update")
end) end)
test("infinite recursion should eventually throw", function() test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
local counter, setCounter = signal(0) local a, setA = signal(0)
local calls = {}
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() effect(function()
setCounter(counter() + 1) 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
end) end)
end) end)
assert(not success, "should have thrown") table.clear(order)
setSrc(true)
assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`)
end) end)
return {} return {}

View file

@ -31,8 +31,8 @@ end)
test("should run indirect scope cleanup", function() test("should run indirect scope cleanup", function()
local cleanups = 0 local cleanups = 0
local cleanup: () -> ()
local cleanup
local stopScope = effectScope(function() local stopScope = effectScope(function()
cleanup = effectScope(function() cleanup = effectScope(function()
onCleanup(function() onCleanup(function()
@ -50,8 +50,8 @@ end)
test("should run indirect effect cleanup", function() test("should run indirect effect cleanup", function()
local cleanups = 0 local cleanups = 0
local a, setA = signal(false) local a, setA = signal(false)
local stopScope = effectScope(function() local stopScope = effectScope(function()
effect(function() effect(function()
a() a()
@ -70,13 +70,26 @@ test("should run indirect effect cleanup", function()
assert(cleanups == 2, "should not cleanup after update") assert(cleanups == 2, "should not cleanup after update")
end) end)
test("should disallow yielding", function() test("should dispose inner effects if created in an effect", function()
local success = pcall(function() local source, setSource = signal(1)
effectScope(function() local triggers = 0
(if task then task.wait else coroutine.yield :: never)() local dispose: () -> ()
effect(function()
dispose = effectScope(function()
effect(function()
source()
triggers += 1
end)
end) end)
assert(triggers == 1, "did not run initially")
end) end)
assert(not success, "should error on yield")
setSource(2)
assert(triggers == 2, "did not run after update")
dispose()
setSource(3)
assert(triggers == 2, "ran after dispose")
end) end)
return {} return {}

View file

@ -0,0 +1,200 @@
--!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 {}

View file

@ -26,7 +26,7 @@ end
immediately closing it, causing alien-signals to unlink the computed value immediately closing it, causing alien-signals to unlink the computed value
from its dependencies and allow it to be garbage collected. from its dependencies and allow it to be garbage collected.
]] ]]
local function unlink(getter: () -> ()) local function unlink(getter: () -> unknown)
effectScope(function() effectScope(function()
getter() getter()
end)() end)()

View file

@ -12,6 +12,10 @@ test.describe("effectScope", function()
require("@self/effectScope.test") require("@self/effectScope.test")
end) end)
test.describe("error", function()
require("@self/errors.test")
end)
test.describe("gc", function() test.describe("gc", function()
require("@self/gc.test") require("@self/gc.test")
end) end)

View file

@ -104,4 +104,25 @@ test("should map keys", function()
assert(state.D == 4, "should add d") assert(state.D == 4, "should add d")
end) 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 {} return {}

View file

@ -12,7 +12,7 @@ test("should observe items", function()
local updates = {} local updates = {}
local cleanups = {} local cleanups = {}
observe(items, function(_, key) observe(items, function(_, key: number)
local get = computed(function() local get = computed(function()
return items()[key] return items()[key]
end) end)
@ -91,11 +91,11 @@ end)
test("should handle recursive additions and removals", function() test("should handle recursive additions and removals", function()
local items, setItems = signal({ 1 }) local items, setItems = signal({ 1 })
local edits = {} local edits: { string } = {}
observe(items, function(item) observe(items, function(item)
if item < 5 then if item < 5 then
setItems(function(state) setItems(function(state: { number })
state = table.clone(state) state = table.clone(state)
table.insert(state, item + 1) table.insert(state, item + 1)
return state return state
@ -114,10 +114,10 @@ end)
test("should handle self removal", function() test("should handle self removal", function()
local items, setItems = signal({ 1, 2, 3, 4 }) local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {} local edits: { string } = {}
observe(items, function(item, index) observe(items, function(item, index)
setItems(function(state) setItems(function(state: { number })
state = table.clone(state) state = table.clone(state)
state[index] = nil state[index] = nil
return state return state
@ -134,7 +134,7 @@ end)
test("should allow disposal during addition", function() test("should allow disposal during addition", function()
local items, setItems = signal({}) local items, setItems = signal({})
local edits = {} local edits = {}
local dispose local dispose: () -> ()
dispose = observe(items, function(item) dispose = observe(items, function(item)
if item == 1 then if item == 1 then
@ -153,7 +153,7 @@ end)
test("should allow disposal during removal", function() test("should allow disposal during removal", function()
local items, setItems = signal({ 1, 2, 3, 4 }) local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {} local edits = {}
local dispose local dispose: () -> ()
dispose = observe(items, function(item) dispose = observe(items, function(item)
table.insert(edits, `+{item}`) table.insert(edits, `+{item}`)
@ -167,4 +167,32 @@ test("should allow disposal during removal", function()
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`) assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
end) 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 {} return {}

View file

@ -6,7 +6,7 @@ local effect = Charm.effect
test("should accept equality function", function() test("should accept equality function", function()
local calls = {} local calls = {}
local src, setSrc = signal(0, function(current, incoming) local src, setSrc = signal(0, function(current: number, incoming: number)
return current == incoming or incoming % 2 == 1 return current == incoming or incoming % 2 == 1
end) end)
@ -46,4 +46,22 @@ test("should not freeze values with metatables", function()
) )
end) 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 {} return {}

View file

@ -46,7 +46,7 @@ test("should run in untracked", function()
end) end)
test("should ensure correct execution order", function() test("should ensure correct execution order", function()
local order = {} local order: { number } = {}
local n1, setN1 = signal(0) local n1, setN1 = signal(0)
local n2, setN2 = signal(0) local n2, setN2 = signal(0)
local sum = computed(function() local sum = computed(function()
@ -72,7 +72,7 @@ test("should ensure correct execution order", function()
end) end)
test("should skip redundant updates", function() test("should skip redundant updates", function()
local order = {} local order: { "even" | "odd" } = {}
local a, setA = signal(0) local a, setA = signal(0)
subscribe(function(): "even" | "odd" subscribe(function(): "even" | "odd"

View file

@ -442,18 +442,16 @@ end)
test.describe("error handling", function() test.describe("error handling", function()
test("should keep graph consistent on errors during activation", function() test("should keep graph consistent on errors during activation", function()
local a = atom(0) local a = atom(0)
local b = computed(function() local b = computed(function(): nil
error("fail") error("fail")
end) end)
local c = computed(function() local c = computed(function()
return a() return a()
end) end)
local success, result = pcall(function() local success, result = pcall(b)
b()
end)
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(tostring(result), "fail"), "should throw fail")
a(1) a(1)
assert(c() == 1, "c should recover and return 1") assert(c() == 1, "c should recover and return 1")
@ -461,7 +459,7 @@ test.describe("error handling", function()
test("should keep graph consistent on errors in computeds", function() test("should keep graph consistent on errors in computeds", function()
local a = atom(0) local a = atom(0)
local b = computed(function() local b = computed(function(): number
if a() == 1 then if a() == 1 then
error("fail") error("fail")
end end
@ -474,11 +472,9 @@ test.describe("error handling", function()
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
a(1) a(1)
local success, result = pcall(function() local success, result = pcall(b)
b()
end)
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(tostring(result), "fail"), "should throw fail")
a(2) a(2)
assert(c() == 2, "c should recover and return 2") assert(c() == 2, "c should recover and return 2")

View file

@ -3,6 +3,7 @@ local test = require("../../../tests/test")
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local listen = Charm.listen
local signal = Charm.signal local signal = Charm.signal
local subscribe = Charm.subscribe local subscribe = Charm.subscribe
local trigger = Charm.trigger local trigger = Charm.trigger
@ -67,7 +68,7 @@ test("should trigger effect once", function()
end) end)
test("should not notify the trigger function sub", function() test("should not notify the trigger function sub", function()
local src1 = signal({}) local src1 = signal({} :: { number })
local src2 = computed(function() local src2 = computed(function()
return src1() return src1()
end) end)
@ -90,9 +91,21 @@ test("should trigger updates for subscribe", function()
triggers += 1 triggers += 1
end) end)
table.insert(src(), 1)
trigger(src) trigger(src)
assert(triggers == 1, "subscribe callback should run after trigger") assert(triggers == 1, "subscribe callback should run after trigger")
end) 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 {} return {}

View file

@ -38,7 +38,7 @@ end)
test("should run detached from effect scope", function() test("should run detached from effect scope", function()
local effects = 0 local effects = 0
local stopEffect local stopEffect: () -> ()
local stopScope = effectScope(function() local stopScope = effectScope(function()
untracked(function() untracked(function()

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/charm" name = "littensy/charm"
version = "0.11.0-rc.5" version = "0.11.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/deep-charm", "name": "@rbxts/deep-charm",
"version": "0.1.0-rc.1", "version": "0.1.1",
"description": "Use Charm with plain Luau tables", "description": "Use Charm with plain Luau tables",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -36,9 +36,9 @@ end
]] ]]
local function toRaw<T>(value: T): T local function toRaw<T>(value: T): T
if isReactive(value) then if isReactive(value) then
local value = value :: ReactiveProxy<T> & T local proxy = value :: ReactiveProxy<T>
value.__track() proxy.__track()
return toRaw(value.__target) return toRaw(proxy.__target)
end end
return value return value
end end
@ -115,7 +115,7 @@ end
@return A reactive proxy if the value is a table @return A reactive proxy if the value is a table
]] ]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) else value return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T
end end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any function ReactiveProxy.__index(self: ReactiveProxy, key: any): any

View file

@ -2,7 +2,10 @@ local Charm = require("../Charm")
local DeepCharm = require("../src") local DeepCharm = require("../src")
local test = require("../../../tests/test") local test = require("../../../tests/test")
local batch = Charm.batch
local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local observe = Charm.observe
local reactive = DeepCharm.reactive local reactive = DeepCharm.reactive
local toRaw = DeepCharm.toRaw local toRaw = DeepCharm.toRaw
@ -49,10 +52,10 @@ test("should unwrap proxy assignments", function()
end) end)
src.a, src.b = src.a, src.b src.a, src.b = src.a, src.b
assert(runs == 1, runs) assert(runs == 1, `bad runs: {runs}`)
src.a, src.b = src.b, src.a src.a, src.b = src.b, src.a
assert(runs == 3, runs) assert(runs == 3, `bad runs: {runs}`)
end) end)
test("should track iteration", function() test("should track iteration", function()
@ -125,10 +128,10 @@ test("should not track unread properties", function()
local _ = src.value.a local _ = src.value.a
calls += 1 calls += 1
end) end)
assert(calls == 1, calls) assert(calls == 1, `bad calls: {calls}`)
src.value.b = 3 src.value.b = 3
assert(calls == 1, calls) assert(calls == 1, `bad calls: {calls}`)
end) end)
test("should overwrite with table passed to setter", function() test("should overwrite with table passed to setter", function()
@ -159,10 +162,10 @@ test("should unwrap proxy", function()
assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value") assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
assert(runs == 1, runs) assert(runs == 1, `bad runs: {runs}`)
src.x = 1 src.x = 1
assert(runs == 2, runs) assert(runs == 2, `bad runs: {runs}`)
end) end)
test("should unwrap nested proxy", function() test("should unwrap nested proxy", function()
@ -177,13 +180,53 @@ test("should unwrap nested proxy", function()
end) end)
assert(toRaw(src) == raw, "unwrap did not return the original table") assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(runs == 1, runs) assert(runs == 1, `bad runs: {runs}`)
inner.x = 1 inner.x = 1
assert(runs == 2, runs) assert(runs == 2, `bad runs: {runs}`)
src.y = 2 src.y = 2
assert(runs == 3, runs) 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)}`)
end) end)
return {} return {}

View file

@ -1,9 +1,9 @@
[package] [package]
name = "littensy/deep-charm" name = "littensy/deep-charm"
version = "0.1.0-rc.1" version = "0.1.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies] [dependencies]
Charm = "littensy/charm@^0.11.0-rc.5" Charm = "littensy/charm@^0.11.0"

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/react-charm", "name": "@rbxts/react-charm",
"version": "0.4.0-rc.3", "version": "0.4.1",
"description": "React bindings for Charm state management", "description": "React bindings for Charm state management",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -13,12 +13,12 @@ local React = require("./React")
@param dependencies An array of values used to memoize the getter. @param dependencies An array of values used to memoize the getter.
@return The current state. @return The current state.
]] ]]
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
local value, setValue = React.useState(getter) local value: T, setValue = React.useState(getter)
React.useLayoutEffect(function() React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setValue) return Charm.listen(getter, setValue :: (T, T?) -> ())
end, dependencies or {}) end, dependencies :: { any } or {})
return value return value
end end
@ -34,13 +34,13 @@ end
@param dependencies An array of values used to memoize the getter. @param dependencies An array of values used to memoize the getter.
@return A binding that stores the current state. @return A binding that stores the current state.
]] ]]
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?) local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
local initialValue = React.useMemo(getter, {}) local initialValue = React.useMemo(getter, {})
local binding, setBinding = React.useBinding(initialValue) local binding, setBinding = React.useBinding(initialValue)
React.useLayoutEffect(function() React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setBinding) return Charm.listen(getter, setBinding :: (T, T?) -> ())
end, dependencies or {}) end, dependencies :: { any } or {})
return binding return binding
end end

View file

@ -13,12 +13,13 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
local function Component(props: Props) local function Component(props: Props)
hook.result = callback(props) hook.result = callback(props)
return nil
end end
function hook.rerender(newProps: Props?) function hook.rerender(newProps: Props?)
props = newProps or props props = newProps or props
ReactRoblox.act(function() ReactRoblox.act(function()
root:render(React.createElement(Component, props)) root:render(React.createElement(Component, props) :: any)
end) end)
end end

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/react-charm" name = "littensy/react-charm"
version = "0.4.0-rc.3" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [
@ -13,5 +13,5 @@ exclude = [
] ]
[dependencies] [dependencies]
Charm = "littensy/charm@^0.11.0-rc.5" Charm = "littensy/charm@^0.11.0"
React = "jsdotlua/react@^17" React = "jsdotlua/react@^17"

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/vide-charm", "name": "@rbxts/vide-charm",
"version": "0.4.0-rc.3", "version": "0.4.1",
"description": "Vide bindings for Charm state management", "description": "Vide bindings for Charm state management",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -11,7 +11,7 @@ local Vide = require("./Vide")
local function useSignalState<State>(getter: () -> State): () -> State local function useSignalState<State>(getter: () -> State): () -> State
local state = Vide.source(getter()) local state = Vide.source(getter())
Vide.cleanup(Charm.listen(getter, state)) Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
return state return state
end end

View file

@ -5,7 +5,7 @@ local test = require("../../../tests/test")
test("tracks signal state", function() test("tracks signal state", function()
local getState, setState = Charm.signal(0) local getState, setState = Charm.signal(0)
local source local source: () -> number
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
source = VideCharm.useSignalState(getState) source = VideCharm.useSignalState(getState)
end) end)
@ -17,7 +17,7 @@ end)
test("accepts a selector", function() test("accepts a selector", function()
local getState, setState = Charm.signal(0) local getState, setState = Charm.signal(0)
local source local source: () -> number
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
source = VideCharm.useSignalState(function() source = VideCharm.useSignalState(function()
return 2 * getState() return 2 * getState()

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/vide-charm" name = "littensy/vide-charm"
version = "0.4.0-rc.4" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [
@ -13,5 +13,5 @@ exclude = [
] ]
[dependencies] [dependencies]
Charm = "littensy/charm@^0.11.0-rc.5" Charm = "littensy/charm@^0.11.0"
Vide = "centau/vide@^0.4" Vide = "centau/vide@^0.4"

713
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,2 +1,4 @@
packages: packages:
- packages/* - packages/*
publicHoistPattern:
- "*@rbxts*"

View file

@ -1,7 +1,6 @@
[tools] [tools]
wally = "UpliftGames/wally@0.3.2" wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.6.1" rojo = "rojo-rbx/rojo@7.7.0"
stylua = "johnnymorganz/stylua@2.3.1" stylua = "johnnymorganz/stylua@2.5.2"
selene = "Kampfkarren/selene@0.30.0" luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
luau-lsp = "johnnymorganz/luau-lsp@1.62.0" lute = "luau-lang/lute@1.0.0"
lute = "luau-lang/lute@0.1.0-nightly.20260219"

View file

@ -1,7 +0,0 @@
std = "roblox"
exclude = ["*/node_modules/*"]
[lints]
global_usage = "allow"
shadowing = "allow"
incorrect_standard_library_use = "allow"

View file

@ -1 +0,0 @@
(require)("@self/run.server")