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
28 changed files with 533 additions and 90 deletions

1
.npmrc
View file

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

149
README.md
View file

@ -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,34 +623,43 @@ 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,
} }
``` ```
#### Server setup
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. When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to:
```luau ```luau
-- sync.server.luau
playerReadyEvent.OnServerEvent:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
-- Sync signal getters, computed signals, atoms, or reactive proxies -- 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)
@ -659,30 +668,54 @@ Players.PlayerRemoving:Connect(function(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 data 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)
``` ```
--- ---
@ -702,38 +735,42 @@ A configuration table that customizes the behavior of Charm Sync on the server.
### `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
playerReadyEvent.OnServerEvent: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 data: 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
playerReadyEvent.OnServerEvent:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
-- 🟢 Good: Player-specific data is synced with a unique key -- 🟢 Good: Player-specific signals are synced with a unique key
[`data-{player.UserId}`] = computed(function() [`playerData-{player.UserId}`] = computed(function()
return getPlayerData(player.UserId) return clientStore.getPlayerData(player.UserId)
end), end),
-- 🔴 Bad: Syncing different data with the same keys does not work -- 🔴 Bad: Syncing different signals with the same keys does not work
playerData = computed(function() playerData = computed(function()
return getPlayerData(player.UserId) return clientStore.getPlayerData(player.UserId)
end), end),
}) })
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)`
@ -742,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")
``` ```
--- ---
@ -807,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,
}) })
``` ```
@ -821,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")
``` ```
--- ---
@ -838,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)
@ -855,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)
@ -869,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.
@ -885,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
@ -940,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

@ -7,7 +7,7 @@
"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.33.3", "packageManager": "pnpm@12.8.1",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/compiler-types": "3.0.0-types.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm-sync", "name": "@rbxts/charm-sync",
"version": "0.4.0-rc.7", "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

@ -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

View file

@ -39,10 +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
value = value :: types.ReactiveProxy<T> local proxy = value :: types.ReactiveProxy<T>
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
value.__track() proxy.__track()
return toRaw(value.__target) return toRaw(proxy.__target)
end end
return value return value
end end
@ -61,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
@ -125,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
@ -146,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

View file

@ -403,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,7 +6,7 @@ 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<<{ [string]: Charm.Atom<number> }>>(input) local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/charm-sync" name = "littensy/charm-sync"
version = "0.4.0-rc.7" 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.6" Charm = "littensy/charm@^0.11.0"

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm", "name": "@rbxts/charm",
"version": "0.11.0-rc.6", "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;
/** /**

View file

@ -68,22 +68,22 @@ local batchDepth = 0
-- Dependencies accessed during this time will link to the active subscriber. -- Dependencies accessed during this time will link to the active subscriber.
local activeSub: ReactiveNode? local activeSub: ReactiveNode?
local function isO2(): boolean local function isStudio(): boolean
return debug.info(1, "n") ~= "isO2" return game ~= nil and game:GetService("RunService"):IsStudio()
end end
local flags = { local 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 = not isO2(), strict = isStudio(),
--[[ --[[
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 = not isO2(), frozen = isStudio(),
--[[ --[[
Whether parent effects should track inner effects and clean them up Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be when the parent effect re-runs. This is usually desirable, but can be
@ -486,7 +486,7 @@ local function signal<T>(initialValue: T, equals: Equals<T>?): (Getter<T>, Sette
end end
-- Check if the new value is different from the pending value -- Check if the new value is different from the pending value
if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then if if equals then not equals(node.pendingValue, value :: T) else node.pendingValue ~= value then
if flags.frozen then if flags.frozen then
deepFreeze(value) deepFreeze(value)
end end

View file

@ -198,4 +198,23 @@ test("should trigger effect even computed already dirty", function()
assert(v() == 2, `expected 2 for v(), got {v()}`) assert(v() == 2, `expected 2 for v(), got {v()}`)
end) 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)
return {} return {}

View file

@ -504,4 +504,90 @@ test("recursive effects should run and cleanup in order", function()
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`) assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
end) end)
test("should effects dispose in order", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect = effect(function()
src()
for i = 1, 3 do
effect(function()
return function()
table.insert(order, i)
end
end)
end
end)
assert(#order == 0, "should not cleanup immediately")
setSrc(true)
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test("should handle inner effect clearing outer effect's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
end)
setSrc(true)
end)
test.skip("should handle inner effect cleanup disposing outer effect", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
table.insert(order, 0)
for i = 1, 4 do
effect(function()
return function()
table.insert(order, i)
stopEffect()
end
end)
end
end)
assert(table.concat(order) == "0", "should not cleanup immediately")
table.clear(order)
setSrc(true)
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test.skip("should old inner effect dispose before outer effect re-runs", function()
local order: { string } = {}
local src, setSrc = signal(false)
effect(function()
src()
table.insert(order, "outer")
effect(function()
return function()
table.insert(order, "inner")
end
end)
end)
table.clear(order)
setSrc(true)
assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`)
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)

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/charm" name = "littensy/charm"
version = "0.11.0-rc.6" 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
value = value :: ReactiveProxy<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
@ -186,4 +189,44 @@ test("should unwrap nested proxy", function()
assert(runs == 3, `bad runs: {runs}`) assert(runs == 3, `bad runs: {runs}`)
end) 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)
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.6" 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.4", "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

@ -14,9 +14,9 @@ local React = require("./React")
@return The current state. @return The current state.
]] ]]
local function useSignalState<T>(getter: () -> T, dependencies: {}?): 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 :: (T, T?) -> ()) return Charm.listen(getter, setValue :: (T, T?) -> ())
end, dependencies :: { any } or {}) end, dependencies :: { any } or {})
@ -38,7 +38,7 @@ 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 :: (T, T?) -> ()) return Charm.listen(getter, setBinding :: (T, T?) -> ())
end, dependencies :: { any } or {}) end, dependencies :: { any } or {})

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/react-charm" name = "littensy/react-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.6" 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.4", "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

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/vide-charm" name = "littensy/vide-charm"
version = "0.4.0-rc.5" 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.6" Charm = "littensy/charm@^0.11.0"
Vide = "centau/vide@^0.4" Vide = "centau/vide@^0.4"

183
pnpm-lock.yaml generated
View file

@ -1,3 +1,186 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 12.8.1
version: 12.8.1
pnpm:
specifier: 12.8.1
version: 12.8.1
packages:
'@pnpm/exe.android-arm64@12.8.1':
resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.8.1':
resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.8.1':
resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.8.1':
resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.8.1':
resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.8.1':
resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.8.1':
resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.8.1':
resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.8.1':
resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.8.1':
resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.8.1':
resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.8.1':
resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.8.1':
resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.8.1':
resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==}
cpu: [x64]
os: [win32]
'@pnpm/exe@12.8.1':
resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==}
engines: {node: '>=18.*'}
hasBin: true
pnpm@12.8.1:
resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.8.1':
optional: true
'@pnpm/exe.android-x64@12.8.1':
optional: true
'@pnpm/exe.darwin-arm64@12.8.1':
optional: true
'@pnpm/exe.darwin-x64@12.8.1':
optional: true
'@pnpm/exe.freebsd-x64@12.8.1':
optional: true
'@pnpm/exe.linux-arm64-musl@12.8.1':
optional: true
'@pnpm/exe.linux-arm64@12.8.1':
optional: true
'@pnpm/exe.linux-ppc64@12.8.1':
optional: true
'@pnpm/exe.linux-riscv64@12.8.1':
optional: true
'@pnpm/exe.linux-s390x@12.8.1':
optional: true
'@pnpm/exe.linux-x64-musl@12.8.1':
optional: true
'@pnpm/exe.linux-x64@12.8.1':
optional: true
'@pnpm/exe.win32-arm64@12.8.1':
optional: true
'@pnpm/exe.win32-x64@12.8.1':
optional: true
'@pnpm/exe@12.8.1':
optionalDependencies:
'@pnpm/exe.android-arm64': 12.8.1
'@pnpm/exe.android-x64': 12.8.1
'@pnpm/exe.darwin-arm64': 12.8.1
'@pnpm/exe.darwin-x64': 12.8.1
'@pnpm/exe.freebsd-x64': 12.8.1
'@pnpm/exe.linux-arm64': 12.8.1
'@pnpm/exe.linux-arm64-musl': 12.8.1
'@pnpm/exe.linux-ppc64': 12.8.1
'@pnpm/exe.linux-riscv64': 12.8.1
'@pnpm/exe.linux-s390x': 12.8.1
'@pnpm/exe.linux-x64': 12.8.1
'@pnpm/exe.linux-x64-musl': 12.8.1
'@pnpm/exe.win32-arm64': 12.8.1
'@pnpm/exe.win32-x64': 12.8.1
pnpm@12.8.1:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.8.1
'@pnpm/exe.android-x64': 12.8.1
'@pnpm/exe.darwin-arm64': 12.8.1
'@pnpm/exe.darwin-x64': 12.8.1
'@pnpm/exe.freebsd-x64': 12.8.1
'@pnpm/exe.linux-arm64': 12.8.1
'@pnpm/exe.linux-arm64-musl': 12.8.1
'@pnpm/exe.linux-ppc64': 12.8.1
'@pnpm/exe.linux-riscv64': 12.8.1
'@pnpm/exe.linux-s390x': 12.8.1
'@pnpm/exe.linux-x64': 12.8.1
'@pnpm/exe.linux-x64-musl': 12.8.1
'@pnpm/exe.win32-arm64': 12.8.1
'@pnpm/exe.win32-x64': 12.8.1
---
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:

View file

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

View file

@ -1,6 +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.4.1" stylua = "johnnymorganz/stylua@2.5.2"
luau-lsp = "johnnymorganz/luau-lsp@1.66.1" luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
lute = "luau-lang/lute@1.0.0" lute = "luau-lang/lute@1.0.0"