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
102 changed files with 4880 additions and 2928 deletions

View file

@ -14,11 +14,19 @@ jobs:
run_install: true run_install: true
- uses: CompeyDev/setup-rokit@v0.1.2 - uses: CompeyDev/setup-rokit@v0.1.2
- name: Setup Lute
run: lute setup --with-luaurc
- name: Check code quality - name: Check code quality
run: sh bin/check.sh run: lute run check
- name: Run ESLint and Prettier
run: |
pnpm eslint packages
pnpm prettier --check packages
- name: Run tests - name: Run tests
run: lune run test/spec.server.luau run: lute run test
- name: Build models - name: Build models
run: sh bin/build.sh run: lute run build

View file

@ -11,8 +11,11 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: CompeyDev/setup-rokit@v0.1.2 - uses: CompeyDev/setup-rokit@v0.1.2
- name: Setup Lute
run: lute setup --with-luaurc
- name: Build models - name: Build models
run: sh bin/build.sh run: lute run build
- name: Upload release assets - name: Upload release assets
uses: AButler/upload-release-assets@v3.0 uses: AButler/upload-release-assets@v3.0

2
.gitignore vendored
View file

@ -2,7 +2,7 @@
/.pesde /.pesde
/build /build
/packages/**/wally.lock /packages/**/wally.lock
/packages/**/LICENSE.md /packages/**/LICENSE*
/packages/**/README.md /packages/**/README.md
/sourcemap.json /sourcemap.json
node_modules node_modules

4
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "test/suite"] [submodule "tests/test"]
path = test/suite path = tests/test
url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4 url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4

View file

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

6
.lute/build.luau Normal file
View file

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

33
.lute/check.luau Normal file
View file

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

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright 2024-present Littensy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,7 +0,0 @@
Copyright 2024 Littensy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

755
README.md

File diff suppressed because it is too large Load diff

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,75 +3,80 @@ 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)
bench.profilebegin("init") bench.profilebegin("init")
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)
bench.profilebegin("init") bench.profilebegin("init")
out() out()
bench.profileend() bench.profileend()
setSrc(updatePercentage(percent))
setSrc(updateRandomly(updateChance))
bench.profilebegin("update") bench.profilebegin("update")
out() out()
bench.profileend() bench.profileend()

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,54 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src)
local patch = CharmSync.patch
local USERS = 1000
local function generate()
local users = {}
for user = 1, USERS do
users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
return { users = users }
end
local function update(state)
state = table.clone(state)
state.users = table.clone(state.users)
for user = 1, USERS do
local rng = math.random()
if rng < 1 / 3 then
continue
elseif rng < 2 / 3 then
state.users[user] = nil
else
state.users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
end
return state
end
return {
ParameterGenerator = function()
local state = generate()
return state, update(state)
end,
Functions = {
diff = function(_, a, b)
patch.diff(a, b)
end,
},
}

88
benches/reactive.luau Normal file
View file

@ -0,0 +1,88 @@
local Charm = require("../packages/charm/src")
local DeepCharm = require("../packages/deep-charm/src")
local bench = require("./bench")
local effect = Charm.effect
local reactive = DeepCharm.reactive
do
local value = reactive({ a = 1 })
bench.run("track reactive property", function()
effect(function()
local _ = value.a
end)
end)
end
do
local value = reactive({ a = 1 })
bench.run("write reactive property", function()
value.a += 1
end)
end
for _, amount in { 10, 100, 1000 } do
print("---")
do
local rawArray = table.create(amount, 1)
local array = reactive(rawArray)
bench.run(`track for loop, {amount} elements`, function()
local sum = 0
effect(function()
for i = 1, #array do
sum += array[i]
end
end)
end)
end
do
local rawArray = table.create(amount, 1)
local array = reactive(rawArray)
bench.run(`track iteration, {amount} elements`, function()
local sum = 0
effect(function()
for _, x in array do
sum += x
end
end)
end)
end
do
local rawArray = table.create(amount, 1)
local array = reactive(rawArray)
effect(function()
local sum = 0
for _, x in array do
sum += x
end
end)
bench.run(`track index mutation (1st only), {amount} elements`, function()
array[1] += 1
end)
end
do
local rawArray = table.create(amount, 1)
local array = reactive(rawArray)
effect(function()
local sum = 0
for _, x in array do
sum += x
end
end)
bench.run(`track index mutation (all), {amount} elements`, function()
for i = 1, #array do
array[i] += 1
end
end)
end
end

View file

@ -1,4 +0,0 @@
#!/usr/bin/env bash
mkdir build
rojo build build.project.json -o build/charm.rbxm

View file

@ -1,19 +0,0 @@
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
rojo sourcemap -o sourcemap.json
luau-lsp analyze \
--defs=bin/roblox.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \
--flag:LuauInstantiateInSubtyping=true \
--sourcemap=sourcemap.json \
--ignore="**/node_modules/**" \
packages test benches
selene packages test benches
stylua --check packages test benches
pnpm eslint packages
rm bin/roblox.d.luau

View file

@ -4,6 +4,7 @@
"$className": "Folder", "$className": "Folder",
"Charm": { "$path": "packages/charm/src" }, "Charm": { "$path": "packages/charm/src" },
"CharmSync": { "$path": "packages/charm-sync/src" }, "CharmSync": { "$path": "packages/charm-sync/src" },
"DeepCharm": { "$path": "packages/deep-charm/src" },
"ReactCharm": { "$path": "packages/react-charm/src" }, "ReactCharm": { "$path": "packages/react-charm/src" },
"VideCharm": { "$path": "packages/vide-charm/src" } "VideCharm": { "$path": "packages/vide-charm/src" }
} }

View file

@ -19,16 +19,25 @@
}, },
"charm": { "charm": {
"$className": "Folder", "$className": "Folder",
"src": { "$path": "packages/charm/src" } "src": { "$path": "packages/charm/src" },
"test": { "$path": "packages/charm/test" }
},
"deep-charm": {
"$className": "Folder",
"src": { "$path": "packages/deep-charm/src" },
"test": { "$path": "packages/deep-charm/test" },
"Charm": { "$path": "packages/deep-charm/Charm.luau" }
}, },
"charm-sync": { "charm-sync": {
"$className": "Folder", "$className": "Folder",
"src": { "$path": "packages/charm-sync/src" }, "src": { "$path": "packages/charm-sync/src" },
"test": { "$path": "packages/charm-sync/test" },
"Charm": { "$path": "packages/charm-sync/Charm.luau" } "Charm": { "$path": "packages/charm-sync/Charm.luau" }
}, },
"react-charm": { "react-charm": {
"$className": "Folder", "$className": "Folder",
"src": { "$path": "packages/react-charm/src" }, "src": { "$path": "packages/react-charm/src" },
"test": { "$path": "packages/react-charm/test" },
"React": { "$path": "packages/react-charm/React.luau" }, "React": { "$path": "packages/react-charm/React.luau" },
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" }, "ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
"Charm": { "$path": "packages/react-charm/Charm.luau" } "Charm": { "$path": "packages/react-charm/Charm.luau" }
@ -36,6 +45,7 @@
"vide-charm": { "vide-charm": {
"$className": "Folder", "$className": "Folder",
"src": { "$path": "packages/vide-charm/src" }, "src": { "$path": "packages/vide-charm/src" },
"test": { "$path": "packages/vide-charm/test" },
"Vide": { "$path": "packages/vide-charm/Vide.luau" }, "Vide": { "$path": "packages/vide-charm/Vide.luau" },
"Charm": { "$path": "packages/vide-charm/Charm.luau" } "Charm": { "$path": "packages/vide-charm/Charm.luau" }
} }
@ -43,8 +53,8 @@
"@rbxts-js": { "@rbxts-js": {
"$path": "node_modules/@rbxts-js" "$path": "node_modules/@rbxts-js"
}, },
"test": { "tests": {
"$path": "test" "$path": "tests"
} }
} }
} }

20
eslint.config.js Normal file
View file

@ -0,0 +1,20 @@
const eslint = require("@eslint/js");
const { defineConfig, globalIgnores } = require("eslint/config");
const prettierRecommended = require("eslint-plugin-prettier/recommended");
const roblox = require("eslint-plugin-roblox-ts");
const tseslint = require("typescript-eslint");
module.exports = defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
roblox.configs.tsRecommendedCompat,
roblox.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"roblox-ts/no-any": "off",
},
},
globalIgnores(["out/**", "eslint.config.js"]),
prettierRecommended,
);

View file

@ -1,20 +0,0 @@
import eslint from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import prettierRecommended from "eslint-plugin-prettier/recommended";
import roblox from "eslint-plugin-roblox-ts";
import tseslint from "typescript-eslint";
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
roblox.configs.tsRecommendedCompat,
roblox.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"roblox-ts/no-any": "off",
},
},
globalIgnores(["out/**"]),
prettierRecommended,
);

View file

@ -1,30 +1,29 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"test": "lune run test/spec.server.luau", "test": "lute run test",
"build": "sh bin/build.sh", "build": "lute run build",
"check": "sh bin/check.sh", "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.22.0", "packageManager": "pnpm@12.8.1",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@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.5-alpha.1", "@rbxts/react": "17.3.7-ts.1",
"@rbxts/react-roblox": "17.3.5-alpha.1", "@rbxts/react-roblox": "17.3.7-ts.1",
"@rbxts/types": "^1.0.883", "@rbxts/types": "^1.0.913",
"@rbxts/vide": "^0.5.7", "@rbxts/vide": "^0.6.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.58.0",
"eslint": "^9.36.0", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-roblox-ts": "^1.2.1", "eslint-plugin-roblox-ts": "^1.4.0",
"jiti": "^2.6.0", "prettier": "^3.8.1",
"prettier": "^3.6.2",
"roblox-ts": "^3.0.0", "roblox-ts": "^3.0.0",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.44.1" "typescript-eslint": "^8.58.0"
} }
} }

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm-sync", "name": "@rbxts/charm-sync",
"version": "0.4.0-rc.1", "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",
@ -11,17 +11,17 @@
"!wally.toml", "!wally.toml",
"!wally.lock", "!wally.lock",
"!default.project.json", "!default.project.json",
"!**/__tests__" "!test"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/littensy/charm.git" "url": "git+https://github.com/littensy/charm.git"
}, },
"scripts": { "scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish" "publish:wally": "wally publish"
}, },
"dependencies": { "dependencies": {
"@rbxts/charm": "workspace:*" "@rbxts/charm": "workspace:^"
} }
} }

View file

@ -1,27 +0,0 @@
local suite = require("../../../../test/suite")
suite.describe("client", function()
require("./client.spec")
end)
suite.describe("e2e", function()
require("./e2e.spec")
end)
suite.describe("utils", function()
require("./utils.spec")
end)
suite.describe("patch", function()
require("./patch.spec")
end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
return {}

View file

@ -1,24 +0,0 @@
local suite = require("../../../../test/suite")
local validate = require("../validate")
local test = suite.test
test("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
}
for key, case in cases do
local success, result: any = pcall(validate, case.value)
if case.expected and not success then
error(`Expected {key} to be valid, threw {result}`)
elseif not case.expected and success then
error(`Expected {key} to be invalid, but it was valid`)
end
end
end)
return {}

View file

@ -3,66 +3,98 @@ local patch = require("./patch")
local types = require("./types") local types = require("./types")
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
type Setter<T = any> = (update: ((T) -> T) | T) -> () local trackedSignals: { [string]: (any) -> any } = {}
local trackedReactives: { [string]: ReactiveProxy } = {}
local trackedSetters: { [string]: Setter } = {} local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
local target = reactive.__target
for key, value in source do
target[key] = value
end
Charm.trigger(reactive.__track)
end
--- Registers a map of setters to the state with the corresponding keys on the --[[
--- server. When the server sends updates, these setters will be called with Registers a map of setters to the state with the corresponding keys on the
--- the new values. server. When the server sends updates, these setters will be called with
--- the new values.
--- Atoms, signals converted to atoms, and signal setter functions are allowed.
--- Note that `server.addSignalsToClient` still requires some way to get the Atoms, signals converted to atoms, and signal setter functions are allowed.
--- state. Note that `server.addSignalsToClient` still requires some way to get the
--- state.
--- @param setters A map of setter functions to sync with the server.
local function addSignals(setters: { [string]: Setter }) @param signals A map of setter functions to sync with the server.
for key, setter in setters do ]]
trackedSetters[key] = setter local function addSignals(signals: { [string]: ((any) -> any) | {} })
for key, signal in signals do
if type(signal) == "function" then
trackedSignals[key] = signal
else
trackedReactives[key] = (signal :: any) :: ReactiveProxy
end
end end
end end
--- Unregisters from server state updates for the given keys. The signals --[[
--- will retain their current values, but will no longer receive updates Unregisters from server state updates for the given keys. The signals
--- from the server. will retain their current values, but will no longer receive updates
from the server.
]]
local function removeSignals(...: string) local function removeSignals(...: string)
for index = 1, select("#", ...) do for index = 1, select("#", ...) do
local key = select(index, ...) local key = select(index, ...)
trackedSetters[key] = nil trackedSignals[key] = nil
trackedReactives[key] = nil
end end
end end
--- Unsubscribes from all server state updates. --[[
Unsubscribes from all server state updates.
]]
local function removeAllSignals() local function removeAllSignals()
table.clear(trackedSetters) table.clear(trackedSignals)
table.clear(trackedReactives)
end end
local function applyInitPayload(data: { [string]: any }) local function applyInitPayload(data: { [string]: any })
for key, value in data do for key, value in data do
local setter = trackedSetters[key] if trackedSignals[key] then
if setter then trackedSignals[key](value)
setter(value) elseif trackedReactives[key] then
shallowMergeReactive(trackedReactives[key], value)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end end
end end
end end
local function applyPatchPayload(data: { [string]: any }) local function applyPatchPayload(data: { [string]: any })
for key, value in data do for key, diffs in data do
local setter = trackedSetters[key] local trackedSignal = trackedSignals[key]
if setter then local trackedReactive = trackedReactives[key]
setter(function(current)
return patch.apply(current, value) if trackedSignal then
trackedSignal(function(value)
return patch.apply(value, diffs)
end) end)
elseif trackedReactive then
patch.applyMutable(trackedReactive.__target, diffs)
Charm.trigger(trackedReactive.__track)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end end
end end
end end
--- Merges the server's state patches into the client's state, either --[[
--- initializing it with the full state or merging a partial patch. Merges the server's state patches into the client's state, either
--- initializing it with the full state or merging a partial patch.
--- @param payloads The state updates received from the server.
local function patch(payloads: { SyncPayload }) @param payloads The state updates received from the server.
]]
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)
@ -76,5 +108,5 @@ return {
addSignals = addSignals, addSignals = addSignals,
removeSignals = removeSignals, removeSignals = removeSignals,
removeAllSignals = removeAllSignals, removeAllSignals = removeAllSignals,
patch = patch, patch = applyPatches,
} }

View file

@ -1,20 +1,31 @@
return { return {
--- The interval at which to send patches to the client, in seconds. --[[
--- Defaults to `0` (patches are sent up to once per frame). Set to a The interval at which to send patches to the client, in seconds.
--- negative value to disable automatic syncing. Defaults to `0` (patches are sent up to once per frame). Set to a
negative value to disable automatic syncing.
]]
interval = 0, interval = 0,
--- Whether the history of state changes since the client's last update --[[
--- should be preserved. This is useful for values that change multiple Whether the history of state changes since the client's last update
--- times per frame, where each individual change is important. Defaults should be preserved. This is useful for values that change multiple
--- to `false`. times per frame, where each individual change is important. Defaults
to `false` for performance reasons.
]]
preserveHistory = false, preserveHistory = false,
--- When `true`, Charm will apply validation and serialize unsafe arrays --[[
--- to address remote event argument limitations. Defaults to `true`. When `true`, Charm will modify sparse arrays to prevent data loss from
--- This option should be disabled if your network library uses a custom remote event limitations. When an unsafe array is detected, numeric
--- serialization method (i.e. Zap, ByteNet) to prevent interference. 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
serialization method (i.e. Zap, ByteNet) to prevent interference.
]]
fixArrays = true, fixArrays = true,
--- When `true`, synced state containing unsafe sparse arrays or mixed --[[
--- tables will throw an error. Only checked in strict mode and if When `true`, synced state containing unsafe sparse arrays or mixed
--- `fixArrays` is enabled. Defaults to `true`. tables will emit a warning. Only checked in strict mode and if
`fixArrays` is enabled. Defaults to `true`.
]]
validatePatches = true, validatePatches = true,
} }

View file

@ -11,8 +11,11 @@ export interface None {
export type MaybeNone<T> = undefined extends T ? None : never; export type MaybeNone<T> = undefined extends T ? None : never;
type GetterMap = Record<string, () => any>; type ReactiveObject = { [key: Key]: any };
type SetterMap = Record<string, Setter<any>>;
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
/** /**
* Infers the type of the return values produced by a map of functions. * Infers the type of the return values produced by a map of functions.
@ -55,7 +58,7 @@ export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State
* A payload that can be sent from the server to the client to synchronize * A payload that can be sent from the server to the client to synchronize
* state between the two. * state between the two.
*/ */
export type SyncPayload<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> = export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Getters> } | { type: "init"; data: StateOfMap<Getters> }
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> }; | { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
@ -75,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.
* *
@ -91,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
@ -112,16 +118,18 @@ export namespace client {
* Note that `server.addSignalsToClient` still requires some way to get the * Note that `server.addSignalsToClient` still requires some way to get the
* state. * state.
* *
* @param setters A map of setter functions to sync with the server. * @param signals A map of setter functions to sync with the server.
*/ */
export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void; export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
/** /**
* Unregisters from server state updates for the given keys. The signals * Unregisters from server state updates for the given keys. The signals
* will retain their current values, but will no longer receive updates * will retain their current values, but will no longer receive updates
* from the server. * from the server.
*/ */
export function removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void; export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
...keys: (keyof Signals)[]
): void;
/** /**
* Unsubscribes from all server state updates. * Unsubscribes from all server state updates.
@ -134,7 +142,7 @@ export namespace client {
* *
* @param payloads The state updates received from the server. * @param payloads The state updates received from the server.
*/ */
export function patch<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>( export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
payloads: SyncPayload<Getters, FixArrays>[], payloads: SyncPayload<Getters, FixArrays>[],
): void; ): void;
} }
@ -152,9 +160,12 @@ export namespace server {
* that `client.addSignals` still requires some way to set the state. * that `client.addSignals` still requires some way to set the state.
* *
* @param client The client receiving state updates. * @param client The client receiving state updates.
* @param getters A map of getter functions to sync with the client. * @param signals A map of getter functions to sync with the client.
*/ */
export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): void; export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
signals: Signals,
): void;
/** /**
* Unsubscribes a client from receiving all state updates. To only unsubscribe * Unsubscribes a client from receiving all state updates. To only unsubscribe
@ -170,9 +181,9 @@ export namespace server {
* @param client The client receiving state updates. * @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from. * @param keys The keys of the state to unsubscribe from.
*/ */
export function removeSignalsFromClient<Getters extends GetterMap = GetterMap>( export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player, client: Player,
...keys: (keyof Getters)[] ...keys: (keyof Signals)[]
): void; ): void;
/** /**
@ -182,7 +193,7 @@ export namespace server {
* *
* @param onSync Called when sending patches to a client. * @param onSync Called when sending patches to a client.
*/ */
export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>( export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void, onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void; ): void;
@ -234,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;
@ -276,5 +287,6 @@ export function isNone(value: unknown): value is None;
* @param getter A function that returns the current value of the signal. * @param getter A function that returns the current value of the signal.
* @param setter A function that sets the value of the signal. * @param setter A function that sets the value of the signal.
* @returns An atom that can either get or set the signal's value. * @returns An atom that can either get or set the signal's value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
*/ */
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>; export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;

View file

@ -7,35 +7,39 @@ local flags = Charm.flags
export type None = types.None export type None = types.None
--- A special symbol that denotes the absence of a value. Used to represent
--- deleted values in patches.
local None: None = { __none = "__none" } local None: None = { __none = "__none" }
--- Checks whether a value is `None`. If `true`, the value is scheduled to be --[[
--- removed from the state when the patch is applied. Checks whether a value is `None`. If `true`, the value is scheduled to be
--- removed from the state when the patch is applied.
--- @param value The value to check.
--- @return `true` if the value is `None`, otherwise `false`. @param value The value to check.
@return `true` if the value is `None`, otherwise `false`.
]]
local function isNone(value: any): boolean local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none" return type(value) == "table" and value.__none == "__none"
end end
--- Converts `nil` values to the special `None` symbol. This is useful when --[[
--- preparing data for serialization, since `nil` can represent both the absence Converts `nil` values to the special `None` symbol. This is useful when
--- of a value and the removal of a value in patches. preparing data for serialization, since `nil` can represent both the absence
of a value and the removal of a value in patches.
]]
local function nilToNone<T>(value: T?): T | None local function nilToNone<T>(value: T?): T | None
return if value == nil then None else value return if value == nil then None else value
end end
--- JSON serialization can drop the last values of a sparse array. For example, --[[
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the JSON serialization can drop the last values of a sparse array. For example,
--- serialization of sparse arrays can be resolved by converting them to `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
--- dictionaries with string keys. serialization of sparse arrays can be resolved by converting them to
--- dictionaries with string keys.
--- The `apply` function will convert these dictionaries back into arrays as
--- long as the target array is not sparse. This function ensures that the diff The `patch` functions will convert these dictionaries back into arrays as
--- function produces a patch that can be correctly applied. long as the target array is not sparse. This function ensures that the diff
local function stringifySparseArray(object: { any }): { [string | number]: any } function produces a patch that can be correctly applied.
]]
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
@ -52,68 +56,78 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
return dictionary return dictionary
end end
--- Compares two states and returns a patch that can be applied to the current local function diff(oldState: any, newState: any): any
--- state to produce the next state. if oldState == newState then
---
--- @param currentState The current state.
--- @param nextState The next state.
--- @return A patch that can be applied to the current state.
local function diff(currentState: any, nextState: any): any
if currentState == nextState then
return nil return nil
elseif nextState == nil then elseif newState == nil then
return None return None
elseif type(currentState) ~= "table" or type(nextState) ~= "table" then elseif type(oldState) ~= "table" or type(newState) ~= "table" then
return nextState return newState
end end
local patches: { [any]: any } = table.clone(nextState) local diffs = table.clone(newState)
for key, previous in currentState do for key, oldValue in oldState do
patches[key] = diff(previous, nextState[key]) diffs[key] = diff(oldValue, newState[key])
end
if next(diffs) == nil then
return nil
end end
if config.fixArrays then if config.fixArrays then
-- Convert sparse array patches that could be misserialized -- Convert sparse array patches that could be misserialized
if currentState[1] ~= nil or nextState[1] ~= nil then if oldState[1] ~= nil or newState[1] ~= nil then
patches = stringifySparseArray(patches) diffs = stringifySparseArray(diffs)
end end
if flags.strict and config.validatePatches then if flags.strict and config.validatePatches then
validate(patches) validate(diffs)
end end
end end
return patches return diffs
end end
--- Applies a patch to a state and returns the resulting value. local function apply(oldState: any, diffs: any): any
--- if isNone(diffs) then
--- @param currentState The current state.
--- @param statePatch The patches to apply.
--- @return The new state with the patch applied.
local function apply(currentState: any, statePatch: any): any
if isNone(statePatch) then
return nil return nil
elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
return statePatch return diffs
end end
local nextState = table.clone(currentState) local newState = table.clone(oldState)
local stateIsArray = currentState[1] ~= nil local stateIsArray = oldState[1] ~= nil
for key, value in statePatch do for key, value in diffs do
-- Diff-checking an array produces a sparse array, which will not be -- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string -- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys. -- keys back into numeric keys.
if stateIsArray and type(key) == "string" then if stateIsArray and type(key) == "string" then
key = tonumber(key) or key key = tonumber(key) or key
end end
newState[key] = apply(newState[key], value)
nextState[key] = apply(nextState[key], value)
end end
return nextState return newState
end
local function applyMutable(targetState: any, diffs: any): any
if isNone(diffs) then
return nil
elseif type(targetState) ~= "table" or type(diffs) ~= "table" then
return diffs
end
local targetIsArray = targetState[1] ~= nil
for key, value in diffs do
if targetIsArray and type(key) == "string" then
key = tonumber(key) or key
end
targetState[key] = applyMutable(targetState[key], value)
end
return targetState
end end
return { return {
@ -121,4 +135,5 @@ return {
nilToNone = nilToNone, nilToNone = nilToNone,
diff = diff, diff = diff,
apply = apply, apply = apply,
applyMutable = applyMutable,
} }

View file

@ -2,6 +2,7 @@ local Charm = require("../Charm")
local config = require("./config") local config = require("./config")
local patch = require("./patch") local patch = require("./patch")
local types = require("./types") local types = require("./types")
local utils = require("./utils")
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
@ -9,10 +10,11 @@ type SyncStatus = number
type ClientKeys = { [string]: SyncStatus } type ClientKeys = { [string]: SyncStatus }
type GetterState<T = any> = { type TrackedState<T = any> = {
getter: () -> T, identifier: unknown,
currentValue: T, currentValue: T,
lastSyncedValue: T, lastSyncedValue: T,
isReactive: boolean,
dispose: () -> (), dispose: () -> (),
} }
@ -25,50 +27,81 @@ type StateUpdate = {
local PENDING_INITIAL_STATE: SyncStatus = 0 local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1 local LISTENING_FOR_CHANGES: SyncStatus = 1
local trackedGetters: { [string]: GetterState } = {} local trackedStates: { [string]: TrackedState } = {}
local globalUpdates: { StateUpdate } = {} local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {} local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection? local connection: RBXScriptConnection?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
local function startTracking<T>(key: string, getter: () -> T) --[[
if trackedGetters[key] then Manually converts a reactive proxy to its raw representation to potentially
-- Ignore duplicates, but warn against name collisions support custom implementations of reactive proxies.
if trackedGetters[key].getter ~= getter then ]]
local function toRaw<T>(value: T): T
local proxy = value :: types.ReactiveProxy<T>
if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
proxy.__track()
return toRaw(proxy.__target)
end
return value
end
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
if trackedStates[key] then
-- Warn against name collisions with conflicting signals or proxies
if trackedStates[key].identifier ~= identifier then
warn("[Charm] Attempted to sync different states with the same key:", key) warn("[Charm] Attempted to sync different states with the same key:", key)
end end
return return
end end
local value = getter() local trackedState: TrackedState<T>
local trackedState = { local initialValue = getter()
getter = getter, local rawInitialValue = toRaw(initialValue)
currentValue = value, local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
lastSyncedValue = value,
} local dispose = Charm.untracked(Charm.effect, function()
local newValue = getter()
local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue
if not trackedState then
return
end
trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
-- When preserving history, we need to keep track of previous values -- When preserving history, we need to keep track of previous values
-- so we can create granular patches for each change. -- so we can create granular patches for each change.
if config.preserveHistory then if config.preserveHistory then
local prevSnapshot = snapshot
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
table.insert(globalUpdates, { table.insert(globalUpdates, {
key = key, key = key,
newValue = newValue, newValue = snapshot,
oldValue = oldValue, oldValue = prevSnapshot,
}) })
end end
trackedState.currentValue = newValue
trackedState.currentValue = rawValue
trackedState.isReactive = isReactive
end) end)
trackedGetters[key] = trackedState trackedState = {
identifier = identifier,
currentValue = rawInitialValue,
lastSyncedValue = snapshot,
isReactive = initialValue ~= rawInitialValue,
dispose = dispose,
}
trackedStates[key] = trackedState
end end
local function stopTracking(key: string) local function stopTracking(key: string)
local trackedState = trackedGetters[key] local trackedState = trackedStates[key]
if trackedState then if trackedState then
trackedState.dispose() trackedState.dispose()
trackedGetters[key] = nil trackedStates[key] = nil
end end
end end
@ -81,7 +114,7 @@ local function purgeUntracked()
end end
end end
for key in trackedGetters do for key in trackedStates do
if not active[key] then if not active[key] then
stopTracking(key) stopTracking(key)
end end
@ -113,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
@ -121,25 +154,27 @@ 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
-- If a pending signal was never updated, we still need to send its initial -- If a pending signal was never updated, we still need to send its initial
-- state. This can happen if a signal was registered after its last change. -- state. This can happen if a signal was registered after its last change.
local initData: { [string]: any } = {} local initData = {}
for key, status in clientKeys do for key, status in clientKeys do
local trackedState = trackedGetters[key] local trackedState = trackedStates[key]
if status == PENDING_INITIAL_STATE and trackedState then if status == PENDING_INITIAL_STATE and trackedState then
initData[key] = trackedState.currentValue initData[key] = trackedState.currentValue
@ -154,20 +189,24 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
return payloads return payloads
end end
local function diffGlobalState(): any local function diffGlobalState(): { [string]: any }?
local currentGlobalState: { [string]: any } = {} local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {}
for key, trackedState in trackedGetters do for key, trackedState in trackedStates do
currentGlobalState[key] = trackedState.currentValue local currentValue = if trackedState.isReactive
then utils.deepCopy(trackedState.currentValue)
else trackedState.currentValue
currentGlobalState[key] = currentValue
previousGlobalState[key] = trackedState.lastSyncedValue previousGlobalState[key] = trackedState.lastSyncedValue
trackedState.lastSyncedValue = trackedState.currentValue trackedState.lastSyncedValue = currentValue
end end
return patch.diff(previousGlobalState, currentGlobalState) return patch.diff(previousGlobalState, currentGlobalState)
end end
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }? local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
local clientKeys = clients[client] local clientKeys = clients[client]
if not clientKeys then if not clientKeys then
@ -181,9 +220,9 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
-- Create patches for initialized signals that changed, and add signals -- Create patches for initialized signals that changed, and add signals
-- that are still pending initial state to the init payload. -- that are still pending initial state to the init payload.
for key, status in clientKeys do for key, status in clientKeys do
local trackedState = trackedGetters[key] local trackedState = trackedStates[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES and globalPatch then
patchData[key] = globalPatch[key] patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue initData[key] = trackedState.currentValue
@ -192,55 +231,97 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
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
end end
--- Subscribes a client to the given signals. When an update occurs, the client local function startInterval(callback: () -> ())
--- will receive a partial state patch to merge into their local state. May be if not game then
--- called multiple times to subscribe to additional keys. return
--- end
--- Atoms, signals converted to atoms, and signal getter functions are allowed.
--- Note that `client.addSignals` still requires some way to set the state. if connection then
--- connection:Disconnect()
--- @param client The client receiving state updates. end
--- @param getters A map of getter functions to sync with the client.
local function addSignalsToClient(client: any, getters: { [string]: () -> any }) local timestamp = os.clock()
connection = game:GetService("RunService").Heartbeat:Connect(function()
if config.interval >= 0 and os.clock() >= timestamp + config.interval then
timestamp = os.clock()
debug.profilebegin("CharmSync")
callback()
debug.profileend()
end
end)
end
--[[
Subscribes a client to the given signals. When an update occurs, the client
will receive a partial state patch to merge into their local state. May be
called multiple times to subscribe to additional keys.
Atoms, signals converted to atoms, and signal getter functions are allowed.
Note that `client.addSignals` still requires some way to set the state.
@param client The client receiving state updates.
@param signals A map of getter functions to sync with the client.
]]
local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
if not clients[client] then if not clients[client] then
clients[client] = {} clients[client] = {}
end end
local clientKeys = clients[client] local clientKeys = clients[client]
for key, getter in getters do for key, signal in signals do
startTracking(key, if type(getter) == "function" then getter else getter.get) if type(signal) == "function" then
trackSignal(key, signal, signal)
else
trackSignal(key, function()
return signal
end, signal)
end
if not clientKeys[key] then if not clientKeys[key] then
-- Mark the getter as pending initial state so we send its full -- Mark the signal as pending initial state so we send its full
-- value the next time we sync. -- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE clientKeys[key] = PENDING_INITIAL_STATE
end end
end end
end end
--- Unsubscribes a client from receiving all state updates. To only unsubscribe --[[
--- from specific keys, use `unsubscribeClientFrom` instead. Unsubscribes a client from receiving all state updates. To only unsubscribe
--- from specific keys, use `unsubscribeClientFrom` instead.
--- @param client The client receiving state updates.
@param client The client receiving state updates.
]]
local function removeClient(client: any) local function removeClient(client: any)
clients[client] = nil clients[client] = nil
purgeUntracked()
end end
--- Unsubscribes a client from receiving updates for the given keys. --[[
--- Unsubscribes a client from receiving updates for the given keys.
--- @param client The client receiving state updates.
--- @param keys The keys of the state to unsubscribe from. @param client The client receiving state updates.
@param keys The keys of the state to unsubscribe from.
]]
local function removeSignalsFromClient(client: any, ...: string) local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client] local clientKeys = clients[client]
@ -255,23 +336,27 @@ local function removeSignalsFromClient(client: any, ...: string)
if next(clientKeys) == nil then if next(clientKeys) == nil then
removeClient(client) removeClient(client)
else
purgeUntracked()
end end
end end
--- Immediately sends pending updates to all clients. Normally, updates are --[[
--- sent at the interval specified in `config.interval`, but this function Immediately sends pending updates to all clients. Normally, updates are
--- can be used to force an immediate sync. sent at the interval specified in `config.interval`, but this function
can be used to force an immediate sync.
]]
local function flush() local function flush()
if not syncCallback then if not syncCallback then
return return
end end
local granular = config.preserveHistory local granular = config.preserveHistory
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
for client in clients do for client in clients do
local payloads = if granular local payloads = if granular
then selectFromGlobalPatchList(client, globalPatch) then selectFromGlobalPatchList(client, globalPatch :: { any })
else selectFromGlobalPatch(client, globalPatch) else selectFromGlobalPatch(client, globalPatch)
if payloads and payloads[1] then if payloads and payloads[1] then
@ -283,46 +368,25 @@ local function flush()
end end
end end
purgeUntracked()
table.clear(globalUpdates) table.clear(globalUpdates)
end end
--- Uses the callback to send state updates to clients that are subscribed --[[
--- to state changes. Starts automatically sending updates at the interval Uses the callback to send state updates to clients that are subscribed
--- specified in `config.interval`. to state changes. Starts automatically sending updates at the interval
--- specified in `config.interval`.
--- @param onSync Called when sending patches to a client.
@param onSync Called when sending patches to a client.
]]
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ()) local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
if connection then
connection:Disconnect()
end
syncCallback = onSync syncCallback = onSync
startInterval(flush)
if not game then
return
end
local timer = 0
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
if config.interval < 0 then
return
end
timer += deltaTime
if timer >= config.interval then
timer = 0
debug.profilebegin("CharmSync")
flush()
debug.profileend()
end
end)
end end
--- Stops syncing state updates to clients at the automatic interval. --[[
--- Flushing can still be performed manually via `flush`. Stops syncing state updates to clients at the automatic interval.
Flushing can still be performed manually via `flush`.
]]
local function disconnect() local function disconnect()
if connection then if connection then
connection:Disconnect() connection:Disconnect()
@ -330,10 +394,12 @@ local function disconnect()
end end
end end
--- Unsubscribes all clients, stops tracking all state, and disconnects --[[
--- from the automatic sync interval. Unsubscribes all clients, stops tracking all state, and disconnects
from the automatic sync interval.
]]
local function reset() local function reset()
table.clear(trackedGetters) table.clear(trackedStates)
table.clear(clients) table.clear(clients)
table.clear(globalUpdates) table.clear(globalUpdates)
disconnect() disconnect()

View file

@ -1,5 +1,7 @@
--- A payload that can be sent from the server to the client to synchronize --[[
--- the states of the client's atoms with the server. A payload that can be sent from the server to the client to synchronize
the states of the client's atoms with the server.
]]
export type SyncPayload = export type SyncPayload =
| { | {
type: "init", type: "init",
@ -10,8 +12,19 @@ export type SyncPayload =
data: { [string]: any }, data: { [string]: any },
} }
--- A special type that represents a removed value in a patch. This is used to --[[
--- differentiate between a value that was set to `nil` and an unchanged value. A special type that represents a removed value in a patch. This is used to
differentiate between a value that was set to `nil` and an unchanged value.
]]
export type None = { __none: "__none" } export type None = { __none: "__none" }
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
return {} return {}

View file

@ -2,28 +2,22 @@ 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.
} Useful for recursively collecting atoms returned by modules.
type FlattenedTable = { @param input A nested table.
[any]: any, @return A flattened table with slash-separated keys.
} ]]
local function flatten<Flattened>(input: { [any]: any }): Flattened
local result = {}
--- Flattens a nested table into a single table with slash-separated keys. local function visit(node: { [any]: any }, path: string)
--- Useful for recursively collecting atoms returned by modules.
---
--- @param input A nested table.
--- @return A flattened table with slash-separated keys.
local function flatten(input: NestedTable): FlattenedTable
local result: FlattenedTable = {}
local function visit(node: NestedTable, path: string)
for key, value in node do for key, value in node do
local location = if path == "" then key else path .. "/" .. key local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
if type(value) == "table" then if type(value) == "table" then
visit(value :: NestedTable, location) visit(value, location)
else else
result[location] = value result[location] = value
end end
@ -32,23 +26,40 @@ local function flatten(input: NestedTable): FlattenedTable
visit(input, "") visit(input, "")
return result return result :: any
end end
--- Converts a signal getter and setter into an atom. This is useful for --[[
--- syncing client signals with the server, as the sync system requires both Converts a signal getter and setter into an atom. This is useful for
--- the getter and setter to update state. syncing client signals with the server, as the sync system requires both
--- the getter and setter to update state.
--- @param getter A function that returns the current value of the signal.
--- @param setter A function that sets the value of the signal. @param getter A function that returns the current value of the signal.
--- @return An atom that can either get or set the signal's value. @param setter A function that sets the value of the signal.
@return An atom that can either get or set the signal's value.
]]
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T> local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
return function(...) return function(...)
return if select("#", ...) == 0 then getter() else setter((...)) return if select("#", ...) == 0 then getter() else setter((...))
end end
end end
--[[
Returns a deep copy of the given table. Skips tables with metatables to
prevent copying reactive proxies.
]]
local function deepCopy<T>(object: T): T
local copy: any = table.clone(object :: any)
for key, value in copy do
if type(value) == "table" and getmetatable(value) == nil then
copy[key] = deepCopy(value)
end
end
return copy
end
return { return {
flatten = flatten, flatten = flatten,
signalToAtom = signalToAtom, signalToAtom = signalToAtom,
deepCopy = deepCopy,
} }

View file

@ -1,18 +1,18 @@
local function isTableUnsafe(object: { [any]: any }) 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
for key in object do for key in object do
if type(key) ~= keyType then if type(key) ~= keyType then
return true return true
elseif keyType == "number" and key > maxKey then elseif type(key) == "number" and key > maxKey then
maxKey = key maxKey = key
end end
end end
@ -25,25 +25,21 @@ local function isTableUnsafe(object: { [any]: any })
return false return false
end end
--- Throws an error if the value is a table that cannot be synced via --[[
--- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes Emits a warning if the value is a table that cannot be synced via
--- with remote argument limitations, such as sparse arrays or mixed tables. RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
--- with remote argument limitations, such as sparse arrays or mixed tables.
--- @param value The table value.
--- @param key The key of the value. @param value The table value.
--- @error Throws an error if the value cannot be synced. ]]
local function validate(value: { [any]: any }) local function validate(value: { [any]: any })
if isTableUnsafe(value) then if isTableUnsafe(value) then
-- Silence warnings during testing warn(
if game then "Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
warn("Cannot sync table:", value)
end
error(
`Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The table is a sparse array with non-sequential indices\n" .. "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" .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
value
) )
end end
end end

View file

@ -1,11 +1,9 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local client = require("../client") local DeepCharm = require("../../deep-charm/src")
local suite = require("../../../../test/suite") local client = require("../src/client")
local test = require("../../../tests/test")
local test = suite.test test.afterEach(function()
local afterEach = suite.afterEach
afterEach(function()
client.removeAllSignals() client.removeAllSignals()
end) end)
@ -40,6 +38,31 @@ test("should merge patch", function()
assert(a().c == 2, "should not change c") assert(a().c == 2, "should not change c")
end) end)
test("should merge patch (reactive)", function()
local a = DeepCharm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()
local _ = a.b + a.c
runs += 1
end)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = { b = a.b, c = a.c } } },
})
assert(runs == 1, "should not trigger effect for redundant init")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a.b == 3, "should set b to the new value")
assert(a.c == 2, "should not change c")
assert(runs == 2, "should trigger effect for patch")
end)
test("should fix sparse array patches", function() test("should fix sparse array patches", function()
local a = Charm.atom({ 1 }) local a = Charm.atom({ 1 })

View file

@ -1,13 +1,11 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local client = require("../client") local DeepCharm = require("../../deep-charm/src")
local config = require("../config") local client = require("../src/client")
local server = require("../server") local config = require("../src/config")
local suite = require("../../../../test/suite") local server = require("../src/server")
local test = require("../../../tests/test")
local afterEach = suite.afterEach test.afterEach(function()
local test = suite.test
afterEach(function()
server.reset() server.reset()
client.removeAllSignals() client.removeAllSignals()
config.preserveHistory = false config.preserveHistory = false
@ -16,9 +14,9 @@ end)
test("should replicate server state to client", function() test("should replicate server state to client", function()
local target = {} local target = {}
local serverA = Charm.atom({ count = 0 }) local serverA = Charm.atom({ count = 0 })
local serverB = Charm.atom({ count = 0 }) local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 }) local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 }) local clientB = DeepCharm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB }) client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB }) server.addSignalsToClient(target, { a = serverA, b = serverB })
@ -30,27 +28,32 @@ test("should replicate server state to client", function()
server.flush() server.flush()
assert(clientA().count == 0, "Client atom A should be initialized to 0") assert(clientA().count == 0, "Client atom A should be initialized to 0")
assert(clientB().count == 0, "Client atom B should be initialized to 0") assert(clientB.count == 0, "Client reactive B should be initialized to 0")
serverA({ count = 1 }) serverA({ count = 1 })
serverB({ count = 1 }) serverB.count = 1
server.flush() server.flush()
assert(clientA().count == 1, "Client atom A should be updated to 1") assert(clientA().count == 1, "Client atom A should be updated to 1")
assert(clientB().count == 1, "Client atom B should be updated to 1") assert(clientB.count == 1, "Client reactive B should be updated to 1")
local runs = -1
Charm.effect(function()
local _ = clientB.count
runs += 1
end)
local prevClientB = clientB()
serverA({ count = 2 }) serverA({ count = 2 })
server.flush() server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2") assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB() == prevClientB, "Client atom B should not be updated") assert(runs == 0, "Client reactive B should not trigger update")
end) end)
test("should replicate historical server state to client", function() test("should replicate historical server state to client", function()
local target = {} local target = {}
local serverA = Charm.atom({ count = 0 }) local serverA = Charm.atom({ count = 0 })
local serverB = Charm.atom({ count = 0 }) local serverB = DeepCharm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 }) local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 }) local clientB = DeepCharm.reactive({ count = -1 })
config.preserveHistory = true config.preserveHistory = true
@ -67,30 +70,28 @@ test("should replicate historical server state to client", function()
Charm.effect(function() Charm.effect(function()
local expected = table.remove(expectedA, 1) local expected = table.remove(expectedA, 1)
assert(expected == clientA().count, `Client atom A should be {expected}`) assert(expected == clientA().count, `Client reactive A should be {expected}`)
end) end)
Charm.effect(function() Charm.effect(function()
local expected = table.remove(expectedB, 1) local expected = table.remove(expectedB, 1)
assert(expected == clientB().count, `Client atom B should be {expected}`) assert(expected == clientB.count, `Client reactive B should be {expected}`)
end) end)
server.flush() server.flush()
serverA({ count = 1 }) serverA({ count = 1 })
serverA({ count = 2 }) serverA({ count = 2 })
serverB({ count = 1 }) serverB.count = 1
serverB({ count = 2 }) serverB.count = 2
server.flush() server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2") assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB().count == 2, "Client atom B should be updated to 2") assert(clientB.count == 2, "Client reactive B should be updated to 2")
local prevClientB = clientB()
serverA({ count = 3 }) serverA({ count = 3 })
serverA({ count = 4 }) serverA({ count = 4 })
server.flush() server.flush()
assert(clientA().count == 4, "Client atom A should be updated to 4") assert(clientA().count == 4, "Client atom A should be updated to 4")
assert(clientB() == prevClientB, "Client atom B should not be updated")
end) end)
return {} return {}

View file

@ -0,0 +1,23 @@
local test = require("../../tests/test")
test.describe("client", function()
require("@self/client.test")
end)
test.describe("e2e", function()
require("@self/e2e.test")
end)
test.describe("utils", function()
require("@self/utils.test")
end)
test.describe("patch", function()
require("@self/patch.test")
end)
test.describe("server", function()
require("@self/server.test")
end)
return {}

View file

@ -1,9 +1,6 @@
local config = require("../config") local config = require("../src/config")
local patch = require("../patch") local patch = require("../src/patch")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local test = suite.test
local afterEach = suite.afterEach
local function mockArraySerdes(object: { [any]: any }): { [any]: any } local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then if object[1] then
@ -23,7 +20,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any }
end end
end end
afterEach(function() test.afterEach(function()
config.fixArrays = true config.fixArrays = true
config.validatePatches = true config.validatePatches = true
end) end)
@ -31,17 +28,17 @@ end)
test("returns a patch", function() test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" } local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" } local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState) local diffs = patch.diff(prevState, nextState)
assert(patches ~= nil, "should return a patch") assert(diffs ~= nil, "should return a patch")
assert(patches.baz == "quux", "should return the changed value") assert(diffs.baz == "quux", "should return the changed value")
assert(patches.foo == nil, "should omit unchanged values") assert(diffs.foo == nil, "should omit unchanged values")
end) end)
test("applies a patch", function() test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" } local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" } local diffs = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches) local applied = patch.apply(prevState, diffs)
assert(applied ~= nil, "should return a new state") assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value") assert(applied.baz == "quux", "should apply the changed value")
@ -49,11 +46,11 @@ test("applies a patch", function()
assert(applied.qaz == "qux", "should add the new value") assert(applied.qaz == "qux", "should add the new value")
end) end)
test("allows nested patches", function() test("allows nested diffs", function()
local prevState = { foo = { bar = "baz" } } local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } } local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState) local diffs = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches) local applied = patch.apply(prevState, diffs)
assert(applied.foo.bar == "qux", "should apply the nested patch") assert(applied.foo.bar == "qux", "should apply the nested patch")
end) end)
@ -78,8 +75,8 @@ test("preserves array density after serdes", function()
local prevState = { "a", "b", "c", "d", "e" } local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" } local nextState = { "A", "b", "c", "D" }
local patches = mockArraySerdes(patch.diff(prevState, nextState)) local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches) local applied = patch.apply(prevState, diffs)
for index in prevState do for index in prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
@ -96,10 +93,10 @@ test("cannot preserve sparse arrays", function()
local prevState = {} local prevState = {}
local nextState = { [2] = "foo" } local nextState = { [2] = "foo" }
local patches = mockArraySerdes(patch.diff(prevState, nextState)) local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches) local applied = patch.apply(prevState, diffs)
-- Not enough information is available to determine whether 'patches' -- Not enough information is available to determine whether 'diffs'
-- should keep the string keys added during remote event serialization. -- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers") assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers") assert(applied[2] == nil, "will fail to convert string keys to numbers")
@ -119,20 +116,18 @@ test("optionally fixes sparse arrays", function()
end end
end) end)
test("optionally validates state", function() test("patches mutable state", function()
local success = pcall(function() local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
config.validatePatches = true patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
patch.diff({}, { [1] = 1, a = true }) assert(targetState.baz == "quux", "should apply the changed value")
end) assert(targetState.foo == "bar", "should keep the unchanged value")
assert(targetState.qaz == "qux", "should add the new value")
end)
assert(not success, "should not allow mixed tables") test("removes table from mutable state", function()
local targetState = { foo = { bar = "baz" } }
success = pcall(function() patch.applyMutable(targetState, { foo = { __none = "__none" } })
config.validatePatches = false assert(targetState.foo == nil, "should remove the table")
patch.diff({}, { [1] = 1, a = true })
end)
assert(success, "should skip validation if not requested")
end) end)
return {} return {}

View file

@ -1,15 +1,12 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local config = require("../config") local DeepCharm = require("../../deep-charm/src")
local patch = require("../patch") local config = require("../src/config")
local server = require("../server") local patch = require("../src/patch")
local suite = require("../../../../test/suite") local server = require("../src/server")
local test = require("../../../tests/test")
local types = require("../src/types")
local afterEach = suite.afterEach test.afterEach(function()
local beforeEach = suite.beforeEach
local describe = suite.describe
local test = suite.test
afterEach(function()
server.reset() server.reset()
end) end)
@ -18,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(...)
@ -45,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(...)
@ -69,11 +66,60 @@ test("should replicate partial state patches", function()
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None") assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
end) end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
a.x.y = 1
a.z = nil
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'")
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
a.z = 1
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'")
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
end)
test("should handle nested proxies", function()
local client = {}
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
server.connect(function(_, newPayloads)
payloads = newPayloads
end)
server.flush()
payloads = nil
server.flush()
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
end)
test("should init new clients", function() 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)
@ -89,8 +135,8 @@ test("should init new clients", function()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
server.addSignalsToClient(clientB, { a = a })
a(2) a(2)
server.addSignalsToClient(clientB, { a = a })
server.flush() server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
@ -102,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(...)
@ -127,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 })
@ -166,24 +212,10 @@ test("should only replicate to synced clients", function()
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B") assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end) end)
test("should validate state", function()
local client = {}
local a = Charm.atom({})
server.addSignalsToClient(client, { a = a })
server.connect(function() end)
a({ [0.5] = true })
assert(not pcall(server.flush), "Expected server.flush to fail")
a({ [0.5] = true, valid = true })
server.flush()
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)
@ -197,12 +229,32 @@ test("should serialize state", function()
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
end) end)
describe("historical", function() test("should not send unchanged state", function()
beforeEach(function() local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
test.describe("historical", function()
test.beforeEach(function()
config.preserveHistory = true config.preserveHistory = true
end) end)
afterEach(function() test.afterEach(function()
config.preserveHistory = false config.preserveHistory = false
end) end)
@ -211,7 +263,7 @@ 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(...)
@ -236,11 +288,41 @@ describe("historical", function()
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'") assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
end) end)
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 })
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
a.value = 1
a.value = 2
b.value = 2
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
end)
test("should init new clients", function() 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)
@ -276,7 +358,7 @@ 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(_, ...)
@ -301,6 +383,74 @@ describe("historical", function()
assert(payloads[4].type == "patch", "Expected fourth payload to be patch") assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
end) end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
test("should maintain patch list order for stale update", function()
local a = Charm.atom({ x = 1 })
local b = Charm.atom({ y = 1 })
local payloads
server.connect(function(_client, _payloads)
payloads = _payloads
end)
server.addSignalsToClient("client", { a = a, b = b })
server.flush()
payloads = nil
a({ x = 1 }) -- new table, same contents: diff is nil
b({ y = 2 })
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "patch", "Expected payload to be patch")
assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'")
end)
end)
test("addSignalsToClient should ignore tracking context", function()
local counter = Charm.atom(0)
local payloads: { any }?
server.connect(function(_client, _payloads)
payloads = _payloads
end)
local dispose = Charm.effectScope(function()
server.addSignalsToClient("PLAYER_ID", { counter = counter })
end)
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'")
dispose()
payloads = nil
counter(1)
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'")
end) end)
return {} return {}

View file

@ -1,16 +1,15 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local utils = require("../utils") local utils = require("../src/utils")
local test = suite.test
test("should flatten functions", function() 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,15 +1,15 @@
[package] [package]
name = "littensy/charm-sync" name = "littensy/charm-sync"
version = "0.4.0-rc.1" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [
"node_modules", "node_modules",
"src/__tests__", "test",
"src/index.d.ts", "src/index.d.ts",
"package.json", "package.json",
"Charm.luau", "Charm.luau",
] ]
[dependencies] [dependencies]
Charm = "littensy/charm@0.11.0-rc.1" Charm = "littensy/charm@^0.11.0"

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm", "name": "@rbxts/charm",
"version": "0.11.0-rc.1", "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",
@ -11,14 +11,14 @@
"!wally.toml", "!wally.toml",
"!wally.lock", "!wally.lock",
"!default.project.json", "!default.project.json",
"!**/__tests__" "!test"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/littensy/charm.git" "url": "git+https://github.com/littensy/charm.git"
}, },
"scripts": { "scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish" "publish:wally": "wally publish"
} }
} }

View file

@ -1,106 +0,0 @@
local Charm = require("../")
local suite = require("../../../../test/suite")
local computed = Charm.computed
local effect = Charm.effect
local signal = Charm.signal
local test = suite.test
test("should correctly propagate changes through computed core", function()
local src, setSrc = signal(0)
local c1 = computed(function()
return src() % 2
end)
local c2 = computed(function()
return c1()
end)
local c3 = computed(function()
return c2()
end)
c3()
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value")
end)
test("should propagate updated source value through chained computations", function()
local src, setSrc = signal(0)
local a = computed(function()
return src()
end)
local b = computed(function()
return a() % 2
end)
local c = computed(function()
return src()
end)
local d = computed(function()
return b() + c()
end)
assert(d() == 0, "bad initial value")
setSrc(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a, setA = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
assert(d() == false, "bad initial value")
setA(true)
assert(d() == true, "bad updated value")
end)
test("should not update if the signal value is reverted", function()
local times = 0
local src, setSrc = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
setSrc(1)
setSrc(0)
c1()
assert(times == 1, "computed ran when it should not")
end)
test("should receive previous value", function()
local calls = {}
local src, setSrc = signal(0)
local c1 = computed(function(current): number
local incoming = src()
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
end)
effect(function()
table.insert(calls, c1())
end)
setSrc(2)
setSrc(3)
assert(c1() == 2, "should skip odd value")
setSrc(4)
setSrc(5)
assert(c1() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end)
return {}

View file

@ -1,66 +0,0 @@
local Charm = require("../")
local suite = require("../../../../test/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local test = suite.test
--- Creates a weak reference to a value.
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
--- Triggers garbage collection by creating a large number of tables.
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
end
end
--- In alien-signals, a computed value unlinks from dependencies when its last
--- parent scope is disposed. However, calling a computed value outside of a
--- scope still links it to dependencies, which act as strong references.
--- Computed values called in this way won't GC until unlinked from a scope.
---
--- This function releases a computed value by linking it to a scope and then
--- immediately closing it, causing alien-signals to unlink the computed value
--- from its dependencies and allow it to be garbage collected.
local function unlink(getter: () -> ())
effectScope(function()
getter()
end)()
end
test("should release computed cache", function()
local srcRef = weakRef({})
local src = atom(srcRef.value)
local c = computed(function()
return src()
end)
c() -- cache src value
src(nil) -- release value
c = unlink(c) -- release computed
gc()
assert(srcRef.value == nil, "should release computed cache")
end)
test("should not release effect for atom", function()
local effectCalls = 0
local src = atom(0)
effect(function()
effectCalls += 1
src()
end)
assert(effectCalls == 1, "should call effect once")
gc()
src(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)
return {}

View file

@ -1,147 +0,0 @@
local Charm = require("../")
local suite = require("../../../../test/suite")
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local peek = Charm.peek
local test = suite.test
test("should not run detached", function()
local a, setA = signal(1)
local spyCalls = 0
effect(function()
a()
peek(function()
effect(function()
return function()
spyCalls += 1
end
end)
end)
end)
assert(spyCalls == 0, "should not be called yet")
setA(2)
assert(spyCalls == 1, "should be called once")
setA(3)
assert(spyCalls == 2, "should be called twice")
end)
test("should block tracking inside effects", function()
local a, setA = signal(1)
local b, setB = signal(2)
local spyCalls = 0
effect(function()
peek(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 1, "should be called once")
setA(10)
setB(20)
assert(spyCalls == 1, "should be called once")
end)
test("should block tracking even when run inside effect run inside peek", function()
local s, setS = signal(1)
local spyCalls = 0
peek(function()
effect(function()
peek(function()
spyCalls += 1
return s()
end)
end)
end)
assert(spyCalls == 1, "should be called once")
setS(2)
assert(spyCalls == 1, "should be called once")
end)
test("should not cause signal assignments to throw", function()
local a, setA = signal(1)
local aChangedTime, setAChangedTime = signal(0)
local dispose = effect(function()
a()
peek(function()
setAChangedTime(aChangedTime() + 1)
end)
end)
setA(2)
assert(aChangedTime() == 2, "aChangedTime should be 2")
setA(3)
assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose()
end)
test("should block tracking inside computed signals", function()
local a, setA = signal(1)
local b, setB = signal(2)
local spyCalls = 0
local c = computed(function()
return peek(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 0, "should not be called yet")
assert(c() == 3, "c should be 3")
setA(10)
c()
setB(20)
c()
assert(spyCalls == 1, "should be called once")
assert(c() == 3, "c should be 3")
end)
test("should track outside peek", function()
local a, setA = signal(1)
local b, setB = signal(2)
local c, setC = signal(3)
local spyCalls = 0
effect(function()
a()
peek(c)
b()
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
setA(10)
assert(spyCalls == 2, "should be called twice")
setB(20)
assert(spyCalls == 3, "should be called three times")
setC(30)
assert(spyCalls == 3, "should not run on c change")
end)
test("should stack peek", function()
local a, setA = signal(1)
local spyCalls = 0
effect(function()
peek(function()
peek(a)
return a()
end)
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
setA(2)
assert(spyCalls == 1, "should not be called twice")
end)
return {}

View file

@ -1,43 +0,0 @@
local suite = require("../../../../test/suite")
suite.describe("computed", function()
require("./computed.spec")
end)
suite.describe("effect", function()
require("./effect.spec")
end)
suite.describe("effectScope", function()
require("./effectScope.spec")
end)
suite.describe("gc", function()
require("./gc.spec")
end)
suite.describe("listen", function()
require("./listen.spec")
end)
suite.describe("mapped", function()
require("./mapped.spec")
end)
suite.describe("observe", function()
require("./observe.spec")
end)
suite.describe("peek", function()
require("./peek.spec")
end)
suite.describe("signal", function()
require("./signal.spec")
end)
suite.describe("subscribe", function()
require("./subscribe.spec")
end)
suite.describe("topology", function()
require("./topology.spec")
end)
suite.describe("trigger", function()
require("./trigger.spec")
end)
suite.describe("untracked", function()
require("./untracked.spec")
end)
return {}

View file

@ -1,15 +1,15 @@
type Key = string | number | symbol; type Key = string | number | symbol;
type Cleanup = () => void; type Cleanup = () => void;
export enum ReactiveFlags { export enum ReactiveFlags {
None = 0b0000000, None = 0b000000,
Mutable = 0b0000001, Mutable = 0b000001,
Watching = 0b0000010, Watching = 0b000010,
RecursedCheck = 0b0000100, RecursedCheck = 0b000100,
Recursed = 0b0001000, Recursed = 0b001000,
Dirty = 0b0010000, Dirty = 0b010000,
Pending = 0b0100000, Pending = 0b100000,
Peeking = 0b1000000,
} }
export interface ReactiveNode { export interface ReactiveNode {
@ -31,11 +31,15 @@ export interface Link {
} }
export interface Atom<T> { export interface Atom<T> {
(newValue: T | ((currentValue: T) => T)): T; (update: Update<T>): T;
(): T; (): T;
} }
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T; export type Getter<T> = () => T;
export type Setter<T> = (update: Update<T>) => T;
export type Update<T> = ((currentValue: T) => T) | T;
export type Equals<T> = (current: T, incoming: T) => boolean; export type Equals<T> = (current: T, incoming: T) => boolean;
@ -46,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;
/** /**
@ -71,13 +75,8 @@ export const flags: {
* @returns A tuple containing the getter and setter functions for the signal. * @returns A tuple containing the getter and setter functions for the signal.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
*/ */
export function signal<T>( export function signal<T>(initialValue: T, equals?: Equals<T>): LuaTuple<[getter: Getter<T>, setter: Setter<T>]>;
initialValue: T, export function signal<T>(): LuaTuple<[getter: Getter<T | undefined>, setter: Setter<T | undefined>]>;
equals?: Equals<T>,
): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>;
export function signal<T>(): LuaTuple<
[getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined]
>;
/** /**
* Creates a reactive atom that acts as both a signal getter and setter. * Creates a reactive atom that acts as both a signal getter and setter.
@ -99,7 +98,7 @@ export function atom<T>(): Atom<T | undefined>;
* @param getter A function that produces the next value. * @param getter A function that produces the next value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
*/ */
export function computed<T>(getter: (previousValue?: T) => T): () => T; export function computed<T>(getter: (previousValue?: T) => T): Getter<T>;
/** /**
* Creates an effect that runs a callback in response to signal state changes. * Creates an effect that runs a callback in response to signal state changes.
@ -150,10 +149,12 @@ export function trigger(fn: () => void): void;
export function onCleanup(fn: Cleanup, failSilently?: boolean): void; export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
/** /**
* Runs the function without subscribing to signal updates or capturing inner * Runs the function without subscribing to signal updates and prevents the
* effects in the parent effect or scope. * current effect or scope from tracking inner effects created within the
* function.
* *
* For an alternative that still cleans up inner effects, use `peek` instead. * To avoid tracking signals while still allowing inner effects to be tracked
* by parent effects/scopes, use `effectScope()` instead.
* *
* @param fn A function that may read signals or create effects. * @param fn A function that may read signals or create effects.
* @param args Arguments to pass to the function. * @param args Arguments to pass to the function.
@ -162,19 +163,6 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
*/ */
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result; export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the function without subscribing to signal updates, but still captures
* inner effects so that they dispose with the parent effect.
*
* To avoid capturing inner effects, use `untracked` instead.
*
* @param fn A function that may read signals.
* @param args Arguments to pass to the function.
* @returns The return value of the function.
* @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
*/
export function peek<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/** /**
* Combines multiple updates into a single "commit" that runs effects and * Combines multiple updates into a single "commit" that runs effects and
* computed signals after the provided function finishes running. * computed signals after the provided function finishes running.
@ -191,54 +179,51 @@ 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: () => 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: () => 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: () => 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,
): () => 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: () => readonly VI[], getter: Getter<readonly VI[]>,
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO, transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
): () => 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: () => { 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,
): () => { readonly [K in KO]: VO }; ): Getter<{ readonly [K in KO]: VO }>;
/** /**
* Calls the callback function for each unique key in the table returned by the * Calls the callback function for each unique key in the table returned by the
@ -252,38 +237,44 @@ 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: () => 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: () => { 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;
/** /**
* Disables recursive checks for the current effect or computed signal. * Returns the currently active subscriber (effect or computed node).
* Useful for effects that intentionally update signals they depend on.
* *
* @example * @return Current active subscriber or `nil`
* ```ts
* effect(() => {
* recursive();
* setCount(getCount() + 1);
* })
* ```
*/ */
export function recursive(): void;
export function getActiveSub(): ReactiveNode | undefined; export function getActiveSub(): ReactiveNode | undefined;
/**
* Sets the currently active subscriber (effect or computed node) and returns
* the previous subscriber.
*
* @param sub New subscriber to set as active
* @return Previous active subscriber
*/
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
/**
* Starts a batch operation, incrementing the batch depth counter. Effects
* will not run until the outermost batch ends.
*/
export function startBatch(): void; export function startBatch(): void;
/**
* Ends a batch operation, decrementing the batch depth counter. If this is
* the outermost batch, queued effects are flushed and run.
*/
export function endBatch(): void; export function endBatch(): void;

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,6 @@
--[[ --[[
Reactive system adapted from alien-signals at Reactive system adapted from alien-signals at
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
MIT License
Copyright (c) 2024-present Johnson Chu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]] ]]
export type ReactiveNode = { export type ReactiveNode = {
@ -50,16 +28,15 @@ type Stack<T> = {
export type ReactiveFlags = number export type ReactiveFlags = number
local NONE = 0b0000000 local NONE = 0b000000
local MUTABLE = 0b0000001 local MUTABLE = 0b000001
local WATCHING = 0b0000010 local WATCHING = 0b000010
local RECURSED_CHECK = 0b0000100 local RECURSED_CHECK = 0b000100
local RECURSED = 0b0001000 local RECURSED = 0b001000
local DIRTY = 0b0010000 local DIRTY = 0b010000
local PENDING = 0b0100000 local PENDING = 0b100000
local PEEKING = 0b1000000
local ReactiveFlags = { local ReactiveFlags = table.freeze({
None = NONE, None = NONE,
Mutable = MUTABLE, Mutable = MUTABLE,
Watching = WATCHING, Watching = WATCHING,
@ -67,161 +44,160 @@ local ReactiveFlags = {
Recursed = RECURSED, Recursed = RECURSED,
Dirty = DIRTY, Dirty = DIRTY,
Pending = PENDING, Pending = PENDING,
}
local function createReactiveSystem(props: {
update: (sub: ReactiveNode) -> boolean,
notify: (sub: ReactiveNode) -> (),
unwatched: (sub: ReactiveNode) -> (),
}) })
local update = props.update
local notify = props.notify
local unwatched = props.unwatched
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean local update: (sub: ReactiveNode) -> boolean
local link = sub.depsTail local notify: (sub: ReactiveNode) -> ()
while link do local unwatched: (sub: ReactiveNode) -> ()
if link == checkLink then
return true local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
end local link = sub.depsTail
link = link.prevDep while link do
if link == checkLink then
return true
end end
return false link = link.prevDep
end end
return false
end
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): () local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
-- DEVIATION: Do not link signals to peeking subscribers local prevDep = sub.depsTail
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then if prevDep and prevDep.dep == dep then
return return
end end
local prevDep = sub.depsTail local nextDep = if prevDep then prevDep.nextDep else sub.deps
if prevDep and prevDep.dep == dep then if nextDep and nextDep.dep == dep then
return nextDep.version = version
end sub.depsTail = nextDep
local nextDep = if prevDep then prevDep.nextDep else sub.deps return
if nextDep and nextDep.dep == dep then end
nextDep.version = version local prevSub = dep.subsTail
sub.depsTail = nextDep if prevSub and prevSub.version == version and prevSub.sub == sub then
return return
end end
local prevSub = dep.subsTail local newLink: Link = {
if prevSub and prevSub.version == version and prevSub.sub == sub then version = version,
return dep = dep,
end sub = sub,
local newLink: Link = { prevDep = prevDep,
version = version, nextDep = nextDep,
dep = dep, prevSub = prevSub,
sub = sub, }
prevDep = prevDep, sub.depsTail = newLink
nextDep = nextDep, dep.subsTail = newLink
prevSub = prevSub, if nextDep then
} nextDep.prevDep = newLink
sub.depsTail = newLink end
dep.subsTail = newLink if prevDep then
if nextDep then prevDep.nextDep = newLink
nextDep.prevDep = newLink else
end sub.deps = newLink
if prevDep then end
prevDep.nextDep = newLink if prevSub then
else prevSub.nextSub = newLink
sub.deps = newLink else
end dep.subs = newLink
if prevSub then end
prevSub.nextSub = newLink end
else
dep.subs = newLink local function unlink(link: Link, sub: ReactiveNode?): Link?
sub = sub or link.sub
local dep = link.dep
local prevDep = link.prevDep
local nextDep = link.nextDep
local nextSub = link.nextSub
local prevSub = link.prevSub
if nextDep then
nextDep.prevDep = prevDep
else
sub.depsTail = prevDep
end
if prevDep then
prevDep.nextDep = nextDep
else
sub.deps = nextDep
end
if nextSub then
nextSub.prevSub = prevSub
else
dep.subsTail = prevSub
end
if prevSub then
prevSub.nextSub = nextSub
else
dep.subs = nextSub
if not nextSub then
unwatched(dep)
end end
end end
return nextDep
end
local function unlink(link: Link, sub: ReactiveNode?): Link? local function propagate(link: Link)
local sub = sub or link.sub repeat
local dep = link.dep local sub = link.sub
local prevDep = link.prevDep local flags = sub.flags
local nextDep = link.nextDep
local nextSub = link.nextSub if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
local prevSub = link.prevSub sub.flags = bit32.bor(flags, PENDING)
if nextDep then elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
nextDep.prevDep = prevDep flags = NONE
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
flags = bit32.band(flags, MUTABLE)
else else
sub.depsTail = prevDep flags = NONE
end end
if prevDep then
prevDep.nextDep = nextDep if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
else notify(sub)
sub.deps = nextDep
end end
if nextSub then
nextSub.prevSub = prevSub if bit32.btest(flags, MUTABLE) then
else local subSubs = sub.subs
dep.subsTail = prevSub if subSubs then
end propagate(subSubs)
if prevSub then
prevSub.nextSub = nextSub
else
dep.subs = nextSub
if not nextSub then
unwatched(dep)
end end
end end
return nextDep
end
local function propagate(link: Link): () link = link.nextSub :: Link
repeat until not link
local sub = link.sub end
local flags = sub.flags
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
flags = NONE
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
flags = bit32.band(flags, MUTABLE)
else
-- selene: allow(if_same_then_else)
flags = NONE
end
local function shallowPropagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub) notify(sub)
end end
end
link = link.nextSub :: Link
until not link
end
if bit32.btest(flags, MUTABLE) then local function checkDirty(link: Link, sub: ReactiveNode): boolean
local subSubs = sub.subs repeat
if subSubs then local dep = link.dep
propagate(subSubs) local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
return true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
shallowPropagate(subs)
end end
end
link = link.nextSub :: Link
until not link
end
local function shallowPropagate(link: Link): ()
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.btest(flags, WATCHING) then
notify(sub)
end
end
link = link.nextSub :: Link
until not link
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
repeat
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
return true return true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if checkDirty(dep.deps :: Link, dep) then
if update(dep) then if update(dep) then
local subs = dep.subs :: Link local subs = dep.subs :: Link
if subs.nextSub then if subs.nextSub then
@ -229,36 +205,33 @@ local function createReactiveSystem(props: {
end end
return true return true
end end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then else
if checkDirty(dep.deps :: Link, dep) then dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
shallowPropagate(subs)
end
return true
end
else
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
end
end end
end
link = link.nextDep :: Link link = link.nextDep :: Link
until not link until not link
return false return false
end end
return { local function createReactiveSystem(
link = link, updateFn: (sub: ReactiveNode) -> boolean,
unlink = unlink, notifyFn: (sub: ReactiveNode) -> (),
propagate = propagate, unwatchedFn: (sub: ReactiveNode) -> ()
checkDirty = checkDirty, )
shallowPropagate = shallowPropagate, update = updateFn
} notify = notifyFn
unwatched = unwatchedFn
end end
return { return {
ReactiveFlags = ReactiveFlags, ReactiveFlags = ReactiveFlags,
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
shallowPropagate = shallowPropagate,
createReactiveSystem = createReactiveSystem, createReactiveSystem = createReactiveSystem,
} }

View file

@ -0,0 +1,220 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local signal = Charm.signal
test("should correctly propagate changes through computed core", function()
local src, setSrc = signal(0)
local c1 = computed(function()
return src() % 2
end)
local c2 = computed(function()
return c1()
end)
local c3 = computed(function()
return c2()
end)
c3()
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value")
end)
test("should propagate updated source value through chained computations", function()
local src, setSrc = signal(0)
local a = computed(function()
return src()
end)
local b = computed(function()
return a() % 2
end)
local c = computed(function()
return src()
end)
local d = computed(function()
return b() + c()
end)
assert(d() == 0, "bad initial value")
setSrc(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a, setA = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
assert(d() == false, "bad initial value")
setA(true)
assert(d() == true, "bad updated value")
end)
test("should not update if the signal value is reverted", function()
local times = 0
local src, setSrc = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
setSrc(1)
setSrc(0)
c1()
assert(times == 1, "computed ran when it should not")
end)
test("should receive previous value", function()
local calls = {}
local src, setSrc = signal(0)
local c1 = computed(function(current: number?): number
local incoming = src()
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
end)
effect(function()
table.insert(calls, c1())
end)
setSrc(2)
setSrc(3)
assert(c1() == 2, "should skip odd value")
setSrc(4)
setSrc(5)
assert(c1() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875
test("should chained computeds keep reactivity when computed effect happens", function()
local v = atom("Hello")
local c = computed(function()
v(v() .. " World")
return v()
end)
local d = computed(function()
return c()
end)
local e = computed(function()
return d()
end)
local res = computed(function()
return d() .. " | " .. e()
end)
assert(res() == "Hello World | Hello World", res())
v(v() .. " World")
assert(res() == "Hello World World World World | Hello World World World World", res())
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580
test("should not trigger effect scheduler by recursive computed effect", function()
local v = atom("Hello")
local c = computed(function()
v(v() .. " World")
return v()
end)
local comp = computed(function()
return c()
end)
assert(comp() == "Hello World", comp())
v(v() .. " World")
assert(comp() == "Hello World World World World", comp())
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528
test("should not override queried MaybeDirty result", function()
local v1 = atom(nil :: any)
local v2 = atom(false)
local c1 = computed(function()
local c = v1()
if not v1() then
c = { v = atom(0) }
v1(c)
end
return c.v()
end)
local c2 = computed(function()
if not v2() then
return "no"
end
return if c1() then "yes" else "no"
end)
local c3 = computed(function()
return c2()
end)
c3()
v2(true)
c3()
v1().v(999)
assert(c3() == "yes", "should return correct value after update")
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503
test("should trigger effect even computed already dirty", function()
local calls: { string } = {}
local v = atom(0)
local c1 = computed(function()
if v() == 0 then
v(1)
end
return "foo"
end)
local c2 = computed(function()
return v() .. c1()
end)
effect(function()
table.insert(calls, c2())
end)
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
assert(v() == 1, `expected 1 for v(), got {v()}`)
v(2)
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
assert(v() == 2, `expected 2 for v(), got {v()}`)
end)
test("should handle inner effect clearing outer computed's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
local c = computed(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
return 0
end)
stopEffect = effect(function()
c()
end)
setSrc(true)
end)
return {}

View file

@ -1,16 +1,14 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope local effectScope = Charm.effectScope
local endBatch = Charm.endBatch local endBatch = Charm.endBatch
local onCleanup = Charm.onCleanup local onCleanup = Charm.onCleanup
local recursive = Charm.recursive
local setActiveSub = Charm.setActiveSub local setActiveSub = Charm.setActiveSub
local signal = Charm.signal local signal = Charm.signal
local startBatch = Charm.startBatch local startBatch = Charm.startBatch
local test = suite.test
test("should clear subscriptions when untracked by all subscribers", function() test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0 local bRunTimes = 0
@ -241,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()
@ -270,33 +271,52 @@ 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 handle effect recursion for the first execution", function() test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
local src1, setSrc1 = signal(0) local a, setA = signal(0)
local src2, setSrc2 = signal(0) local b, setB = signal(0)
local c, setC = signal(0)
local triggers1 = 0 local d = computed(function()
local triggers2 = 0 c()
return 0
effect(function()
triggers1 += 1
setSrc1(math.min(src1() + 1, 5))
end)
effect(function()
triggers2 += 1
setSrc2(math.min(src2() + 1, 5))
src2()
end) end)
assert(triggers1 == 1, `bad triggers1: {triggers1}`) local effect3Executed = false
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
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) end)
test("should opt in to recursion", function() test("should allow finite recursion", function()
local src, setSrc = signal(0) local src, setSrc = signal(0)
local order = {} local order = {}
effect(function() effect(function()
recursive()
table.insert(order, src()) table.insert(order, src())
setSrc(math.min(src() + 1, 5)) setSrc(math.min(src() + 1, 5))
end) end)
@ -388,4 +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("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
local a, setA = signal(0)
local calls = {}
effect(function()
table.insert(calls, a())
return function()
setA(2)
end
end)
assert(#calls == 1, "should have run once initially")
table.clear(calls)
setA(1)
assert(#calls == 1, "should run once after an update")
assert(calls[1] == 2, `bad call: {calls[1]}`)
end)
test("should run the cleanup if the effect disposes itself", function()
local a, setA = signal(0)
local calls = 0
local dispose: () -> ()
dispose = effect(function()
if a() > 0 then
dispose()
return function()
calls += 1
end
end
end)
assert(calls == 0, "should not have called cleanup initially")
setA(1)
assert(calls == 1, "should have called cleanup once after first update")
setA(2)
assert(calls == 1, "should not call cleanup again after second update")
end)
test("should not run the effect if the cleanup function disposes it", function()
local a, setA = signal(0)
local calls = 0
local dispose: () -> ()
dispose = effect(function()
a()
calls += 1
return function()
dispose()
end
end)
assert(calls == 1, "should have run effect once initially")
setA(1)
assert(calls == 1, "should not run effect again after update")
end)
test("should reset the cleanup if the effect throws", function()
local a, setA = signal(0)
local calls = 0
effect(function()
if a() == 0 then
return function()
calls += 1
end
else
error("hello")
end
end)
assert(calls == 0, "should not have called cleanup initially")
local success, err = pcall(setA, 1)
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
assert(calls == 1, "should have called cleanup once after throwing")
setA(0)
assert(calls == 1, "should not call cleanup again after resetting value")
end)
test("recursive effects should run and cleanup in order", function()
local a, setA = signal(0)
local order: { string } = {}
effect(function()
local value = a()
table.insert(order, `+{value}`)
if value < 3 then
setA(value + 1)
end
return function()
table.insert(order, `-{value}`)
end
end)
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
table.clear(order)
setA(100)
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
end)
test("should effects dispose in order", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect = effect(function()
src()
for i = 1, 3 do
effect(function()
return function()
table.insert(order, i)
end
end)
end
end)
assert(#order == 0, "should not cleanup immediately")
setSrc(true)
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test("should handle inner effect clearing outer effect's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
end)
setSrc(true)
end)
test.skip("should handle inner effect cleanup disposing outer effect", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
table.insert(order, 0)
for i = 1, 4 do
effect(function()
return function()
table.insert(order, i)
stopEffect()
end
end)
end
end)
assert(table.concat(order) == "0", "should not cleanup immediately")
table.clear(order)
setSrc(true)
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test.skip("should old inner effect dispose before outer effect re-runs", function()
local order: { string } = {}
local src, setSrc = signal(false)
effect(function()
src()
table.insert(order, "outer")
effect(function()
return function()
table.insert(order, "inner")
end
end)
end)
table.clear(order)
setSrc(true)
assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`)
end)
return {} return {}

View file

@ -1,11 +1,10 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope local effectScope = Charm.effectScope
local signal = Charm.signal local signal = Charm.signal
local onCleanup = Charm.onCleanup local onCleanup = Charm.onCleanup
local test = suite.test
test("should not trigger after stop", function() test("should not trigger after stop", function()
local count, setCount = signal(1) local count, setCount = signal(1)
@ -32,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()
@ -51,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()
@ -71,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

@ -0,0 +1,65 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
end
end
--[[
In alien-signals, a computed value unlinks from dependencies when its last
parent scope is disposed. However, calling a computed value outside of a
scope still links it to dependencies, which act as strong references.
Computed values called in this way won't GC until unlinked from a scope.
This function releases a computed value by linking it to a scope and then
immediately closing it, causing alien-signals to unlink the computed value
from its dependencies and allow it to be garbage collected.
]]
local function unlink(getter: () -> unknown)
effectScope(function()
getter()
end)()
end
test("should release computed cache", function()
local srcRef = weakRef({})
local src, setSrc = signal(srcRef.value)
local c = computed(function()
return src()
end)
c() -- cache src value
setSrc(nil) -- release value
unlink(c) -- release computed
gc()
assert(srcRef.value == nil, "should release computed cache")
end)
test("should not release effect for atom", function()
local effectCalls = 0
local src, setSrc = signal(0)
effect(function()
effectCalls += 1
src()
end)
assert(effectCalls == 1, "should call effect once")
gc()
setSrc(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)
return {}

View file

@ -0,0 +1,55 @@
local test = require("../../tests/test")
test.describe("computed", function()
require("@self/computed.test")
end)
test.describe("effect", function()
require("@self/effect.test")
end)
test.describe("effectScope", function()
require("@self/effectScope.test")
end)
test.describe("error", function()
require("@self/errors.test")
end)
test.describe("gc", function()
require("@self/gc.test")
end)
test.describe("listen", function()
require("@self/listen.test")
end)
test.describe("mapped", function()
require("@self/mapped.test")
end)
test.describe("observe", function()
require("@self/observe.test")
end)
test.describe("signal", function()
require("@self/signal.test")
end)
test.describe("subscribe", function()
require("@self/subscribe.test")
end)
test.describe("topology", function()
require("@self/topology.test")
end)
test.describe("trigger", function()
require("@self/trigger.test")
end)
test.describe("untracked", function()
require("@self/untracked.test")
end)
return {}

View file

@ -1,10 +1,8 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local signal = Charm.signal local signal = Charm.signal
local listen = Charm.listen local listen = Charm.listen
local effect = Charm.effect
local test = suite.test
test("should listen to selector", function() test("should listen to selector", function()
local source, setSource = signal(0) local source, setSource = signal(0)
@ -27,32 +25,23 @@ test("should listen to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub") assert(current == 2 and previous == 1, "should not run after unsub")
end) end)
test("should run in peek", function() test("should run in untracked", function()
local runs = 0 local runs = 0
local cleanups = 0
local a, setA = signal(0) local a, setA = signal(0)
local b, setB = signal(0) local b, setB = signal(0)
listen(a, function() listen(a, function()
runs += 1 runs += 1
b() b()
effect(function()
return function()
cleanups += 1
end
end)
end) end)
assert(runs == 1, "should run once on init") assert(runs == 1, "should run once on init")
assert(cleanups == 0, "should not cleanup yet")
setA(1) setA(1)
assert(runs == 2, "should run on a change") assert(runs == 2, "should run once on change")
assert(cleanups == 1, "should cleanup first call")
setB(1) setB(1)
assert(runs == 2, "should not run on b change") assert(runs == 2, "should not run on b change")
assert(cleanups == 1, "should not cleanup second call") setA(2)
assert(runs == 3, "should run on a change")
end) end)
test("should run if first value is nil", function() test("should run if first value is nil", function()
@ -68,36 +57,6 @@ test("should run if first value is nil", function()
assert(runs == 2, "should run once on change") assert(runs == 2, "should run once on change")
end) end)
test("should track inner subs", function()
local order = {}
local a, setA = signal(1)
local unsub = listen(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
table.clear(order)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
setA(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end)
test("should skip redundant updates", function() test("should skip redundant updates", function()
local order = {} local order = {}
local a, setA = signal(0) local a, setA = signal(0)

View file

@ -1,9 +1,8 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local signal = Charm.signal local signal = Charm.signal
local mapped = Charm.mapped local mapped = Charm.mapped
local test = suite.test
test("should map state", function() test("should map state", function()
local src, setSrc = signal({ 1, 2, 3 }) local src, setSrc = signal({ 1, 2, 3 })
@ -105,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

@ -1,19 +1,18 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local signal = Charm.signal local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope local effectScope = Charm.effectScope
local observe = Charm.observe local observe = Charm.observe
local test = suite.test
test("should observe items", function() test("should observe items", function()
local items, setItems = signal({ 0, 0, 0 }) local items, setItems = signal({ 0, 0, 0 })
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)
@ -92,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
@ -115,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
@ -135,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
@ -154,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}`)
@ -168,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

@ -1,13 +1,12 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local signal = Charm.signal local signal = Charm.signal
local effect = Charm.effect local effect = Charm.effect
local test = suite.test
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)
@ -47,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

@ -1,12 +1,9 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local signal = Charm.signal local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local subscribe = Charm.subscribe local subscribe = Charm.subscribe
local listen = Charm.listen
local effect = Charm.effect
local test = suite.test
test("should subscribe to selector", function() test("should subscribe to selector", function()
local source, setSource = signal(0) local source, setSource = signal(0)
@ -29,40 +26,27 @@ test("should subscribe to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub") assert(current == 2 and previous == 1, "should not run after unsub")
end) end)
test("should run in peek", function() test("should run in untracked", function()
local runs = 0 local runs = 0
local cleanups = 0
local a, setA = signal(0) local a, setA = signal(0)
local b, setB = signal(0) local b, setB = signal(0)
subscribe(a, function() subscribe(a, function()
runs += 1 runs += 1
b() b()
effect(function()
return function()
cleanups += 1
end
end)
end) end)
assert(runs == 0, "should not run once on init") assert(runs == 0, "should not run once on init")
assert(cleanups == 0, "should not cleanup yet")
setA(1) setA(1)
assert(runs == 1, "should run once on change") assert(runs == 1, "should run once on change")
assert(cleanups == 0, "should not cleanup yet")
setB(1) setB(1)
assert(runs == 1, "should not run on b change") assert(runs == 1, "should not run on b change")
assert(cleanups == 0, "should not cleanup first call")
setA(2) setA(2)
assert(runs == 2, "should run on a change") assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call")
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()
@ -87,35 +71,8 @@ test("should ensure correct execution order", function()
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end) end)
test("should track inner subs", function()
local order = {}
local a, setA = signal(1)
local unsub = subscribe(a, function()
table.insert(order, 1)
listen(a, function()
table.insert(order, 2)
end)
listen(a, function()
table.insert(order, 3)
end)
end)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order)
unsub()
setA(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
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

@ -1,11 +1,9 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local atom = Charm.atom local atom = Charm.atom
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local describe = suite.describe
local test = suite.test
--[[ --[[
Tests adopted with thanks from preact-core implementation at Tests adopted with thanks from preact-core implementation at
@ -34,7 +32,7 @@ local test = suite.test
SOFTWARE SOFTWARE
]] ]]
describe("graph updates", function() test.describe("graph updates", function()
test("should drop A->B->A updates", function() test("should drop A->B->A updates", function()
-- A -- A
-- / | -- / |
@ -441,21 +439,19 @@ describe("graph updates", function()
end) end)
end) end)
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")
@ -463,7 +459,7 @@ 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
@ -476,11 +472,9 @@ 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

@ -1,20 +1,18 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") 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
local test = suite.test
local beforeEach = suite.beforeEach
local afterEach = suite.afterEach
beforeEach(function() test.beforeEach(function()
Charm.flags.frozen = false Charm.flags.frozen = false
end) end)
afterEach(function() test.afterEach(function()
Charm.flags.frozen = true Charm.flags.frozen = true
end) end)
@ -69,6 +67,22 @@ test("should trigger effect once", function()
assert(triggers == 2, "effect should run twice") assert(triggers == 2, "effect should run twice")
end) end)
test("should not notify the trigger function sub", function()
local src1 = signal({} :: { number })
local src2 = computed(function()
return src1()
end)
effect(function()
src1()
src2()
end)
trigger(function()
src1()
src2()
end)
end)
test("should trigger updates for subscribe", function() test("should trigger updates for subscribe", function()
local src = signal({} :: { number }) local src = signal({} :: { number })
local triggers = 0 local triggers = 0
@ -77,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

@ -1,12 +1,11 @@
local Charm = require("../") local Charm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local signal = Charm.signal local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope local effectScope = Charm.effectScope
local untracked = Charm.untracked local untracked = Charm.untracked
local test = suite.test
test("should run detached from effect", function() test("should run detached from effect", function()
local a, setA = signal(1) local a, setA = signal(1)
@ -39,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,8 +1,8 @@
[package] [package]
name = "littensy/charm" name = "littensy/charm"
version = "0.11.0-rc.1" 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", "src/__tests__", "src/index.d.ts", "package.json"] exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies] [dependencies]

View file

@ -0,0 +1,5 @@
if not script then
return require("../charm/src")
end
return require(script.Parent.Parent.charm.src)

View file

@ -0,0 +1,6 @@
{
"name": "deep-charm",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,27 @@
{
"name": "@rbxts/deep-charm",
"version": "0.1.1",
"description": "Use Charm with plain Luau tables",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
}
}

53
packages/deep-charm/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,53 @@
export type ReactiveProxy<T extends object = object> = T & {
__track: () => void;
__target: T;
__signals: Map<unknown, () => unknown>;
};
/**
* Creates a deep reactive value that tracks properties when they're accessed.
* Returns a proxy object that subscribes to changes on any nested property
* you read, and a function for mutating the original table.
*
* @example
* ```luau
* local users, updateUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* updateUsers(function(state)
* state.user.name = "Jane"
* state.user.surname = "Smith"
* end)
* ```
*
* @param initialValue The value to make deeply reactive.
* @returns A proxy table and a function to mutate the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function reactive<T extends object>(
initialValue: T,
): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>;
/**
* Determines if the provided value is a reactive proxy created by `reactive()`.
*
* @param value A value to test.
* @returns `true` if the value is a reactive proxy, `false` otherwise.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function isReactive(value: unknown): boolean;
/**
* Unwraps a reactive proxy to get the original table. If the provided value
* is not a proxy, it is returned as-is.
*
* @param value A reactive proxy or a non-proxy value.
* @returns The unwrapped target value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function toRaw<T>(value: T): T;

View file

@ -0,0 +1,165 @@
local Charm = require("./Charm")
local computed = Charm.computed
local trigger = Charm.trigger
local getActiveSub = Charm.getActiveSub
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
--[[
Determines if the provided value is a reactive proxy created by `reactive()`.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function isReactive(value: any): boolean
return type(value) == "table" and getmetatable(value) == ReactiveProxy
end
--[[
Unwraps a reactive proxy to get the original table. If the provided value
is not a proxy, it is returned as-is.
@param value A reactive proxy or a non-proxy value
@return The source table if `value` is a reactive proxy
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if isReactive(value) then
local proxy = value :: ReactiveProxy<T>
proxy.__track()
return toRaw(proxy.__target)
end
return value
end
--[[
Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property
you read, and a function for mutating the original table.
@example
```luau
local users, updateUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
updateUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
end)
```
@param initialValue Source table to make deeply reactive
@return A proxy table that tracks reads to nested properties
@return A function for batching mutations to the source table
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
initialValue = toRaw(initialValue)
local proxy = ReactiveProxy.proxyMap[initialValue]
if not proxy then
local track = computed(function(version: number?)
-- Reference the proxy to avoid GCing while an effect is actively
-- tracking this reactive object.
local _ref = proxy
-- Allow updates from the parent to propagate down when the
-- target is mutated directly through the setter.
if trackParent then
trackParent()
end
return (version or 0) + 1
end)
proxy = setmetatable({
__target = initialValue,
__signals = setmetatable({}, { __mode = "v" }) :: any,
__track = track,
}, ReactiveProxy)
ReactiveProxy.proxyMap[initialValue] = proxy
end
local function setValue(update: ((T) -> ())?)
if update then
update(proxy.__target)
end
trigger(proxy.__track)
end
return proxy :: any, setValue
end
--[[
Wraps a value in `reactive()` if it is a table; otherwise, returns the
value as-is.
@param value Source value to make deeply reactive
@param trackParent Optional tracking function to propagate deep updates
@return A reactive proxy if the value is a table
]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T
end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
local track = self.__track
local signals = self.__signals
local target = self.__target
if getActiveSub() then
if not signals[key] then
signals[key] = computed(function()
track()
return target[key]
end)
end
signals[key]()
end
return toReactive(target[key], track)
end
function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
value = toRaw(value)
if self.__target[key] ~= value then
self.__target[key] = value
trigger(self.__track)
end
end
function ReactiveProxy.__iter(self: ReactiveProxy)
self.__track()
local function nextReactive(target: any, prevKey: any)
local nextKey, nextValue = next(target, prevKey)
return nextKey, toReactive(nextValue, self.__track)
end
return nextReactive, self.__target
end
function ReactiveProxy.__len(self: ReactiveProxy)
self.__track()
return #self.__target
end
return {
reactive = reactive,
isReactive = isReactive,
toRaw = toRaw,
}

View file

@ -0,0 +1,79 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
type ReactiveProxy<T = any> = DeepCharm.ReactiveProxy<T>
local computed = Charm.computed
local effect = Charm.effect
local reactive = DeepCharm.reactive
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
end
end
test("should release inner reactive proxy", function()
local valueRef = weakRef({})
local src: any = reactive({ value = valueRef.value })
local dispose = effect(function()
local _ = src.value -- create proxy
end)
src.value = nil :: any -- release proxy
dispose()
gc()
assert(src.__signals.value == nil, "should release signals")
assert(valueRef.value == nil, "should release inner proxy")
end)
test("should release reactive property deps", function()
local src: any = reactive({ foo = 1 })
local c = computed(function()
return src.foo
end)
c()
assert(src.__signals.foo ~= nil, "should have deps for foo")
c = nil :: any
gc()
assert(src.__signals.foo == nil, "should release deps for foo")
end)
test("should not release effect for reactive", function()
local src = reactive({ foo = 1 })
local calls = 0
effect(function()
calls += 1
local _ = src.foo
end)
assert(calls == 1, "should call effect once")
gc()
src.foo += 1
assert(calls == 2, "should call effect again")
end)
test("should not release tracked reactive", function()
local src = reactive({ users = {} :: { [string]: {}? } })
local user
effect(function()
user = src.users.user
end)
gc()
src.users.user = {}
assert(user ~= nil, "src.users.user should not be released")
end)
return {}

View file

@ -0,0 +1,11 @@
local test = require("../../tests/test")
test.describe("reactive", function()
require(`@self/reactive.test`)
end)
test.describe("gc", function()
require(`@self/gc.test`)
end)
return {}

View file

@ -0,0 +1,232 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
local batch = Charm.batch
local computed = Charm.computed
local effect = Charm.effect
local observe = Charm.observe
local reactive = DeepCharm.reactive
local toRaw = DeepCharm.toRaw
test("should track nested properties", function()
local src, setSrc = reactive({ a = { b = { c = 0 } } })
local calls = {}
effect(function()
table.insert(calls, `{src.a.b.c}`)
end)
setSrc(function(state)
state.a.b.c = 1
end)
src.a.b.c = 2
src.a.b.c = 2
setSrc()
assert(table.concat(calls) == "012", table.concat(calls))
end)
test("should track object type changes", function()
local src = reactive({ a = { b = 0 } } :: { a: any })
local calls = {}
effect(function()
table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
end)
src.a = { b = 0 }
src.a = 1
src.a = { b = 2 }
src.a = nil
src.a = 3
assert(table.concat(calls) == "0012nil3", table.concat(calls))
end)
test("should unwrap proxy assignments", function()
local src = reactive({ a = { x = 0 }, b = { x = 0 } })
local runs = 0
effect(function()
local _, _ = src.a.x, src.b.x
runs += 1
end)
src.a, src.b = src.a, src.b
assert(runs == 1, `bad runs: {runs}`)
src.a, src.b = src.b, src.a
assert(runs == 3, `bad runs: {runs}`)
end)
test("should track iteration", function()
local src, setSrc = reactive({} :: { [string]: number })
local calls = {}
effect(function()
for key, value in src do
table.insert(calls, `{key}={value};`)
end
end)
src.a = 1
assert(table.concat(calls) == "a=1;", table.concat(calls))
table.clear(calls)
src.b = 2
assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
table.clear(calls)
src.a = 2
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
table.clear(calls)
setSrc()
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
end)
test("should track iteration for nested tables", function()
local src = reactive({} :: { [string]: { value: number } })
local calls = {}
effect(function()
for key, obj in src do
table.insert(calls, `{key}={obj.value};`)
end
end)
src.a = { value = 1 }
table.clear(calls)
src.b = { value = 1 }
assert(table.concat(calls) == "a=1;b=1;", table.concat(calls))
table.clear(calls)
src.a.value = 2
assert(table.concat(calls) == "a=2;b=1;", table.concat(calls))
end)
test("should track array length", function()
local src = reactive({ array = {} :: { number } })
local calls = {}
effect(function()
table.insert(calls, #src.array)
end)
src.array[#src.array + 1] = 1
src.array[#src.array + 1] = 2
src.array[#src.array + 1] = 3
src.array = { 1, 2, 3, 4 }
assert(table.concat(calls) == "01234", table.concat(calls))
end)
test("should not track unread properties", function()
local src = reactive({ value = { a = 1, b = 2 } })
local calls = 0
effect(function()
local _ = src.value.a
calls += 1
end)
assert(calls == 1, `bad calls: {calls}`)
src.value.b = 3
assert(calls == 1, `bad calls: {calls}`)
end)
test("should overwrite with table passed to setter", function()
local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number? })
local calls = {}
effect(function()
table.insert(calls, `{src.a}{src.b}{src.c}`)
end)
setSrc(function(src)
src.a = 2
src.b = nil
src.c = 4
end)
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
end)
test("should unwrap proxy", function()
local raw = {}
local src = reactive(raw)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
assert(runs == 1, `bad runs: {runs}`)
src.x = 1
assert(runs == 2, `bad runs: {runs}`)
end)
test("should unwrap nested proxy", function()
local raw = {}
local inner = reactive(raw)
local src = reactive(inner)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(runs == 1, `bad runs: {runs}`)
inner.x = 1
assert(runs == 2, `bad runs: {runs}`)
src.y = 2
assert(runs == 3, `bad runs: {runs}`)
end)
test("should observe items", function()
local items, updateItems = reactive({ 0, 0, 0 })
local updates = {}
local cleanups = {}
observe(function()
return toRaw(items)
end, function(_, key: number)
local get = computed(function()
return items[key]
end)
effect(function()
table.insert(updates, get())
end)
return function()
table.insert(cleanups, key)
end
end)
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
table.clear(updates)
updateItems(function(state)
state[1] = 1
state[2] = 2
state[3] = 3
end)
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
table.clear(updates)
batch(function()
items[3] = nil
items[4] = 4
end)
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
end)
return {}

View file

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

View file

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

View file

@ -1,12 +1 @@
type CoreReactBinding<T> = {
getValue: (self: CoreReactBinding<T>) -> T,
_source: string?,
}
type ReactBindingMap = {
map: <T, U>(self: CoreReactBinding<T> & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding<U>,
}
export type Binding<T> = CoreReactBinding<T> & ReactBindingMap
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React) return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/react-charm", "name": "@rbxts/react-charm",
"version": "0.4.0-rc.1", "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",
@ -11,20 +11,20 @@
"!wally.toml", "!wally.toml",
"!wally.lock", "!wally.lock",
"!default.project.json", "!default.project.json",
"!**/__tests__" "!test"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/littensy/charm.git" "url": "git+https://github.com/littensy/charm.git"
}, },
"scripts": { "scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish" "publish:wally": "wally publish"
}, },
"dependencies": { "dependencies": {
"@rbxts/charm": "workspace:*" "@rbxts/charm": "workspace:^"
}, },
"peerDependencies": { "peerDependencies": {
"@rbxts/react": "*" "@rbxts/react": ">=17.0.0"
} }
} }

View file

@ -1,11 +0,0 @@
local suite = require("../../../../test/suite")
suite.describe("useSignalState", function()
require("./useSignalState.spec")
end)
suite.describe("useSignalBinding", function()
require("./useSignalBinding.spec")
end)
return {}

View file

@ -1,54 +1,52 @@
local Charm = require("./Charm") local Charm = require("./Charm")
local React = require("./React") local React = require("./React")
--- Returns a React state that updates when the getter function updates. --[[
--- Re-renders the component when the value changes. Returns a React state that updates when the getter function updates.
--- Re-renders the component when the value changes.
--- If the `dependencies` array is provided, a new effect is created for the
--- current `getter` when the dependencies change. Otherwise, the effect is
--- created only once, when the component is mounted.
---
--- @param getter The signal or selector to watch.
--- @param dependencies An array of values used to memoize the getter.
--- @return The current state.
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
local value, setValue = React.useState(getter)
React.useLayoutEffect(function() If the `dependencies` array is provided, a new effect is created for the
return Charm.effect(function() current `getter` when the dependencies change. Otherwise, the effect is
-- Wrap in untracked to avoid tracking inner effect and atom calls created only once, when the component is mounted.
Charm.untracked(setValue, getter())
end) @param getter The signal or selector to watch.
end, dependencies or {}) @param dependencies An array of values used to memoize the getter.
@return The current state.
]]
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
local value: T, setValue = React.useState(getter)
React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setValue :: (T, T?) -> ())
end, dependencies :: { any } or {})
return value return value
end end
--- Returns a React binding that updates when the getter function updates. --[[
--- Returns a React binding that updates when the getter function updates.
--- If the `dependencies` array is provided, a new effect is created for the
--- current `getter` when the dependencies change. Otherwise, the effect is If the `dependencies` array is provided, a new effect is created for the
--- created only once, when the component is mounted. current `getter` when the dependencies change. Otherwise, the effect is
--- created only once, when the component is mounted.
--- @param getter The atom or selector to watch.
--- @param dependencies An array of values used to memoize the getter. @param getter The atom or selector to watch.
--- @return A binding that stores the current state. @param dependencies An array of values used to memoize the getter.
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?) @return A binding that stores the current state.
]]
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.effect(function() return Charm.listen(getter, setBinding :: (T, T?) -> ())
-- Wrap in untracked to avoid tracking inner effect and atom calls end, dependencies :: { any } or {})
Charm.untracked(setBinding, getter())
end)
end, dependencies or {})
return binding return binding
end end
return { return table.freeze({
useSignalState = useSignalState, useSignalState = useSignalState,
useSignalBinding = useSignalBinding, useSignalBinding = useSignalBinding,
useAtom = useSignalState, useAtom = useSignalState,
} })

View file

@ -1,7 +1,5 @@
_G.__ROACT_17_MOCK_SCHEDULER__ = true local React = require("../../React")
local ReactRoblox = require("../../ReactRoblox")
local React = require("../../../React")
local ReactRoblox = require("../../../ReactRoblox")
type RenderedHook<Props, Result> = { type RenderedHook<Props, Result> = {
result: Result, result: Result,
@ -15,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

@ -0,0 +1,13 @@
local test = require("../../tests/test")
_G.__ROACT_17_MOCK_SCHEDULER__ = true
test.describe("useSignalState", function()
require("@self/useSignalState.test")
end)
test.describe("useSignalBinding", function()
require("@self/useSignalBinding.test")
end)
return {}

View file

@ -1,9 +1,7 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local ReactCharm = require("../") local ReactCharm = require("../src")
local renderHook = require("./helpers/renderHook") local renderHook = require("./helpers/renderHook")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local test = suite.test
test("tracks signal state", function() test("tracks signal state", function()
local getState, setState = Charm.signal(0) local getState, setState = Charm.signal(0)

View file

@ -1,9 +1,7 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local ReactCharm = require("../") local ReactCharm = require("../src")
local renderHook = require("./helpers/renderHook") local renderHook = require("./helpers/renderHook")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local test = suite.test
test("tracks signal state", function() test("tracks signal state", function()
local getState, setState = Charm.signal(0) local getState, setState = Charm.signal(0)

View file

@ -1,11 +1,11 @@
[package] [package]
name = "littensy/react-charm" name = "littensy/react-charm"
version = "0.4.0-rc.1" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [
"node_modules", "node_modules",
"src/__tests__", "test",
"src/index.d.ts", "src/index.d.ts",
"package.json", "package.json",
"React.luau", "React.luau",
@ -13,5 +13,5 @@ exclude = [
] ]
[dependencies] [dependencies]
Charm = "littensy/charm@0.11.0-rc.1" Charm = "littensy/charm@^0.11.0"
React = "jsdotlua/react@^17" React = "jsdotlua/react@^17"

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/vide-charm", "name": "@rbxts/vide-charm",
"version": "0.4.0-rc.1", "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",
@ -11,18 +11,18 @@
"!wally.toml", "!wally.toml",
"!wally.lock", "!wally.lock",
"!default.project.json", "!default.project.json",
"!**/__tests__" "!test"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/littensy/charm.git" "url": "git+https://github.com/littensy/charm.git"
}, },
"scripts": { "scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish" "publish:wally": "wally publish"
}, },
"dependencies": { "dependencies": {
"@rbxts/charm": "workspace:*" "@rbxts/charm": "workspace:^"
}, },
"peerDependencies": { "peerDependencies": {
"@rbxts/vide": "*" "@rbxts/vide": "*"

View file

@ -1,7 +0,0 @@
local suite = require("../../../../test/suite")
suite.describe("useSignalState", function()
require("./useSignalState.spec")
end)
return {}

View file

@ -1,18 +1,17 @@
local Charm = require("./Charm") local Charm = require("./Charm")
local Vide = require("./Vide") local Vide = require("./Vide")
--- Returns a reactive source that subscribes to the current value of the given --[[
--- signal or getter function. Returns a reactive source that subscribes to the current value of the given
--- signal or getter function.
--- @param getter The getter function to subscribe to.
--- @return The reactive source. @param getter The getter function to subscribe to.
@return The reactive source.
]]
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.effect(function() Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
-- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(state :: (State) -> State, getter())
end))
return state return state
end end

View file

@ -0,0 +1,7 @@
local test = require("../../tests/test")
test.describe("useSignalState", function()
require("@self/useSignalState.test")
end)
return {}

View file

@ -1,13 +1,11 @@
local Charm = require("../../Charm") local Charm = require("../Charm")
local Vide = require("../../Vide") local Vide = require("../Vide")
local VideCharm = require("../") local VideCharm = require("../src")
local suite = require("../../../../test/suite") local test = require("../../../tests/test")
local test = suite.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)
@ -19,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,11 +1,11 @@
[package] [package]
name = "littensy/vide-charm" name = "littensy/vide-charm"
version = "0.4.0-rc.1" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [
"node_modules", "node_modules",
"src/__tests__", "test",
"src/index.d.ts", "src/index.d.ts",
"package.json", "package.json",
"Vide.luau", "Vide.luau",
@ -13,5 +13,5 @@ exclude = [
] ]
[dependencies] [dependencies]
Charm = "littensy/charm@0.11.0-rc.1" Charm = "littensy/charm@^0.11.0"
Vide = "centau/vide@^0.3" Vide = "centau/vide@^0.4"

1134
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,13 +1,6 @@
# This file lists tools managed by Rokit, a toolchain manager for Roblox projects.
# For more information, see https://github.com/rojo-rbx/rokit
# New tools can be added by running `rokit add <tool>` in a terminal.
[tools] [tools]
wally = "UpliftGames/wally@0.3.2" wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.6.0" rojo = "rojo-rbx/rojo@7.7.0"
stylua = "johnnymorganz/stylua@2.3.0" stylua = "johnnymorganz/stylua@2.5.2"
selene = "Kampfkarren/selene@0.29.0" luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
luau-lsp = "johnnymorganz/luau-lsp@1.55.0" lute = "luau-lang/lute@1.0.0"
lune = "lune-org/lune@0.10.4"
lute = "luau-lang/lute@0.1.0-nightly.20250923"

View file

@ -1,6 +0,0 @@
std = "selene_defs"
exclude = ["*/node_modules/*"]
[lints]
global_usage = "allow"
shadowing = "allow"

View file

@ -1,6 +0,0 @@
base: roblox
name: selene_defs
globals:
require:
args:
- type: string

View file

@ -1,23 +0,0 @@
local Charm = require("../packages/charm/src")
local suite = require("./suite")
Charm.flags.strict = true
Charm.flags.frozen = true
suite.describe("charm", function()
require("../packages/charm/src/__tests__/spec")
end)
suite.describe("charm-sync", function()
require("../packages/charm-sync/src/__tests__/spec")
end)
suite.describe.ingame("react-charm", function()
require("../packages/react-charm/src/__tests__/spec")
end)
suite.describe.ingame("vide-charm", function()
require("../packages/vide-charm/src/__tests__/spec")
end)
suite.finish()

@ -1 +0,0 @@
Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38

33
tests/run.server.luau Normal file
View file

@ -0,0 +1,33 @@
local Charm = require("../packages/charm/src")
local test = require("./test")
Charm.flags.strict = true
Charm.flags.frozen = true
local describeInRoblox = test.describeIf(function()
return not not game
end)
test.describe("charm", function()
require("../packages/charm/test")
end)
test.describe("charm-sync", function()
require("../packages/charm-sync/test")
end)
test.describe("deep-charm", function()
require("../packages/deep-charm/test")
end)
describeInRoblox("react-charm", function()
require("../packages/react-charm/test")
end)
describeInRoblox("vide-charm", function()
require("../packages/vide-charm/test")
end)
test.finish()
return {}

Some files were not shown because too many files have changed in this diff Show more