mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
Compare commits
13 commits
charm-v0.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af4eb5262d | ||
|
|
1b856b065e | ||
|
|
aa84fa1e3d | ||
|
|
2fc3be34d0 | ||
|
|
c31e5c5a34 | ||
|
|
73fbfb2685 | ||
|
|
b05f3a9de6 | ||
|
|
fdb506dc23 | ||
|
|
ba870a8c2c | ||
|
|
29be66fa94 | ||
|
|
d09fb5536b | ||
|
|
573533058f | ||
|
|
d0b7cff943 |
147 changed files with 9499 additions and 4368 deletions
26
.eslintrc
26
.eslintrc
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"jsx": true,
|
||||
"useJSXTextNode": true,
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"roblox-ts",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:roblox-ts/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off"
|
||||
}
|
||||
}
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
* text=auto eol=lf
|
||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
|
|
@ -1,31 +1,32 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: true
|
||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Install Rokit
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Install Wally dependencies
|
||||
run: pnpm wally
|
||||
- name: Setup Lute
|
||||
run: lute setup --with-luaurc
|
||||
|
||||
- name: Check code quality
|
||||
run: pnpm check
|
||||
run: lute run check
|
||||
|
||||
- name: Run ESLint and Prettier
|
||||
run: |
|
||||
pnpm eslint packages
|
||||
pnpm prettier --check packages
|
||||
|
||||
- name: Run tests
|
||||
run: lute run test
|
||||
|
||||
- name: Build models
|
||||
run: pnpm build
|
||||
run: lute run build
|
||||
|
|
|
|||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -9,13 +9,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Install Rokit
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
- name: Setup Lute
|
||||
run: lute setup --with-luaurc
|
||||
|
||||
- name: Build models
|
||||
run: pnpm build
|
||||
run: lute run build
|
||||
|
||||
- name: Upload release assets
|
||||
uses: AButler/upload-release-assets@v3.0
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,13 +1,13 @@
|
|||
/.vscode
|
||||
/node_modules
|
||||
/.pesde
|
||||
/build
|
||||
/modules/DevPackages
|
||||
/packages/**/node_modules
|
||||
/packages/**/wally.lock
|
||||
/packages/**/LICENSE*
|
||||
/packages/**/README.md
|
||||
/packages/**/LICENSE.md
|
||||
/sourcemap.json
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
*.rbxm
|
||||
*.tgz
|
||||
.DS_Store
|
||||
roblox.d.luau
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "tests/test"]
|
||||
path = tests/test
|
||||
url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
|
||||
6
.luaurc
6
.luaurc
|
|
@ -1,3 +1,7 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
"languageMode": "strict",
|
||||
"aliases": {
|
||||
"lute": "~/.lute/typedefs/1.0.0/lute",
|
||||
"std": "~/.lute/typedefs/1.0.0/std"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
.lute/build.luau
Normal file
6
.lute/build.luau
Normal 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
33
.lute/check.luau
Normal 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
1
.lute/test.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
(require)("../tests/run.server")
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 255 KiB |
48
benches/bench.luau
Normal file
48
benches/bench.luau
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
local profiles: { [string]: number } = {}
|
||||
local times: { [string]: number } = {}
|
||||
local stack: { string } = {}
|
||||
|
||||
local function run(name: string, test: () -> ())
|
||||
local start = os.clock()
|
||||
local iter = 0
|
||||
|
||||
table.clear(profiles)
|
||||
table.clear(times)
|
||||
table.clear(stack)
|
||||
|
||||
while os.clock() - start < 2 do
|
||||
iter += 1
|
||||
test()
|
||||
end
|
||||
|
||||
local elapsed = os.clock() - start
|
||||
local results = {}
|
||||
|
||||
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||
|
||||
for name, duration in times do
|
||||
table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`)
|
||||
end
|
||||
|
||||
print(table.concat(results, " | "))
|
||||
end
|
||||
|
||||
local function profilebegin(name: string)
|
||||
profiles[name] = os.clock()
|
||||
table.insert(stack, name)
|
||||
end
|
||||
|
||||
local function profileend()
|
||||
local name = table.remove(stack)
|
||||
|
||||
if name then
|
||||
times[name] = (times[name] or 0) + os.clock() - profiles[name]
|
||||
profiles[name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
run = run,
|
||||
profilebegin = profilebegin,
|
||||
profileend = profileend,
|
||||
}
|
||||
84
benches/mapped.luau
Normal file
84
benches/mapped.luau
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
local Charm = require("../packages/charm/src")
|
||||
local bench = require("./bench")
|
||||
|
||||
local computed = Charm.computed
|
||||
local mapped = Charm.mapped
|
||||
local signal = Charm.signal
|
||||
|
||||
type Key = number
|
||||
type Value = number
|
||||
|
||||
local count = 512
|
||||
local initialState: { [Key]: Value } = table.create(count, 0)
|
||||
|
||||
local function expensiveOperation()
|
||||
for _ = 1, 100 do
|
||||
continue
|
||||
end
|
||||
end
|
||||
|
||||
local function simpleMapped(
|
||||
source: () -> { [Key]: Value },
|
||||
mapper: (Value, Key) -> (Value, Key?)
|
||||
): () -> { [Key]: Value }
|
||||
return computed(function()
|
||||
local state = source()
|
||||
local nextState = {}
|
||||
|
||||
for keyIn, valueIn in state do
|
||||
local valueOut, keyOut = mapper(valueIn, keyIn)
|
||||
nextState[if keyOut == nil then keyIn else keyOut] = valueOut
|
||||
end
|
||||
|
||||
return nextState
|
||||
end)
|
||||
end
|
||||
|
||||
local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
|
||||
return function(state)
|
||||
local nextState = table.clone(state)
|
||||
for key in state do
|
||||
if math.random() < updateChance then
|
||||
nextState[key] += 1
|
||||
end
|
||||
end
|
||||
return nextState
|
||||
end
|
||||
end
|
||||
|
||||
for _, updateChance in { 0.1, 0.5, 1 } do
|
||||
local function mapper(value: Value, key: Key): (Value, Key?)
|
||||
expensiveOperation()
|
||||
return value + 1, key + 1
|
||||
end
|
||||
|
||||
bench.run(`mapped {updateChance}`, function()
|
||||
local src, setSrc = signal(initialState)
|
||||
local out = mapped(src, mapper)
|
||||
|
||||
bench.profilebegin("init")
|
||||
out()
|
||||
bench.profileend()
|
||||
|
||||
setSrc(updateRandomly(updateChance))
|
||||
|
||||
bench.profilebegin("update")
|
||||
out()
|
||||
bench.profileend()
|
||||
end)
|
||||
|
||||
bench.run(`control {updateChance}`, function()
|
||||
local src, setSrc = signal(initialState)
|
||||
local out = simpleMapped(src, mapper)
|
||||
|
||||
bench.profilebegin("init")
|
||||
out()
|
||||
bench.profileend()
|
||||
|
||||
setSrc(updateRandomly(updateChance))
|
||||
|
||||
bench.profilebegin("update")
|
||||
out()
|
||||
bench.profileend()
|
||||
end)
|
||||
end
|
||||
188
benches/multi.luau
Normal file
188
benches/multi.luau
Normal 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
|
||||
47
benches/propagate.luau
Normal file
47
benches/propagate.luau
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
local Charm = require("../packages/charm/src")
|
||||
local bench = require("./bench")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local listen = Charm.listen
|
||||
local signal = Charm.signal
|
||||
|
||||
for _, w in { 10, 100 } do
|
||||
for _, h in { 10, 100 } do
|
||||
bench.run(`propagate {w}*{h}`, function()
|
||||
local src, setSrc = signal(1)
|
||||
for _ = 1, w do
|
||||
local last = src
|
||||
for _ = 1, h do
|
||||
local prev = last
|
||||
last = computed(function()
|
||||
return prev() + 1
|
||||
end)
|
||||
end
|
||||
effect(function()
|
||||
last()
|
||||
end)
|
||||
end
|
||||
setSrc(src() + 1)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
for _, w in { 10, 100 } do
|
||||
for _, h in { 10, 100 } do
|
||||
bench.run(`propagate (listen) {w}*{h}`, function()
|
||||
local src, setSrc = signal(1)
|
||||
for _ = 1, w do
|
||||
local last = src
|
||||
for _ = 1, h do
|
||||
local prev = last
|
||||
last = computed(function()
|
||||
return prev() + 1
|
||||
end)
|
||||
end
|
||||
listen(last, function() end)
|
||||
end
|
||||
setSrc(src() + 1)
|
||||
end)
|
||||
end
|
||||
end
|
||||
88
benches/reactive.luau
Normal file
88
benches/reactive.luau
Normal 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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
mkdir build
|
||||
rojo build packages/charm -o build/charm.rbxm
|
||||
rojo build packages/charm-sync -o build/charm-sync.rbxm
|
||||
rojo build packages/react -o build/react-charm.rbxm
|
||||
rojo build packages/vide -o build/vide-charm.rbxm
|
||||
29
bin/check.sh
29
bin/check.sh
|
|
@ -1,29 +0,0 @@
|
|||
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
|
||||
|
||||
rojo sourcemap -o sourcemap.json
|
||||
|
||||
check() {
|
||||
echo "Checking $1"
|
||||
|
||||
luau-lsp analyze \
|
||||
--defs=bin/roblox.d.luau \
|
||||
--defs=bin/testez.d.luau \
|
||||
--flag:LuauFixIndexerSubtypingOrdering=true \
|
||||
--flag:LuauInstantiateInSubtyping=true \
|
||||
--sourcemap=sourcemap.json \
|
||||
--ignore="**/_Index/**" \
|
||||
$1
|
||||
|
||||
selene $1
|
||||
stylua --check $1
|
||||
}
|
||||
|
||||
eslint packages
|
||||
|
||||
for i in packages/*/src; do
|
||||
check $i
|
||||
done
|
||||
|
||||
check tests
|
||||
|
||||
rm bin/roblox.d.luau
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
cd modules
|
||||
wally install
|
||||
cd ..
|
||||
rojo sourcemap --output sourcemap.json
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
declare function afterAll(callback: () -> ()): ()
|
||||
declare function afterEach(callback: () -> ()): ()
|
||||
|
||||
declare function beforeAll(callback: () -> ()): ()
|
||||
declare function beforeEach(callback: () -> ()): ()
|
||||
|
||||
declare function describe(phrase: string, callback: () -> ()): ()
|
||||
declare function describeFOCUS(phrase: string, callback: () -> ()): ()
|
||||
declare function fdescribe(phrase: string, callback: () -> ()): ()
|
||||
declare function describeSKIP(phrase: string, callback: () -> ()): ()
|
||||
declare function xdescribe(phrase: string, callback: () -> ()): ()
|
||||
|
||||
declare function expect(value: any): any
|
||||
|
||||
declare function FIXME(optionalMessage: string?): ()
|
||||
declare function FOCUS(): ()
|
||||
declare function SKIP(): ()
|
||||
|
||||
declare function it(phrase: string, callback: () -> ()): ()
|
||||
declare function itFOCUS(phrase: string, callback: () -> ()): ()
|
||||
declare function fit(phrase: string, callback: () -> ()): ()
|
||||
declare function itSKIP(phrase: string, callback: () -> ()): ()
|
||||
declare function xit(phrase: string, callback: () -> ()): ()
|
||||
declare function itFIXME(phrase: string, callback: () -> ()): ()
|
||||
11
build.project.json
Normal file
11
build.project.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "Packages",
|
||||
"tree": {
|
||||
"$className": "Folder",
|
||||
"Charm": { "$path": "packages/charm/src" },
|
||||
"CharmSync": { "$path": "packages/charm-sync/src" },
|
||||
"DeepCharm": { "$path": "packages/deep-charm/src" },
|
||||
"ReactCharm": { "$path": "packages/react-charm/src" },
|
||||
"VideCharm": { "$path": "packages/vide-charm/src" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,59 @@
|
|||
{
|
||||
"name": "charm-test",
|
||||
"globIgnorePaths": [
|
||||
"**/default.project.json",
|
||||
"node_modules/charm",
|
||||
"node_modules/charm-sync",
|
||||
"node_modules/react-charm",
|
||||
"node_modules/vide-charm"
|
||||
],
|
||||
"emitLegacyScripts": false,
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"DevPackages": {
|
||||
"$path": "modules/DevPackages",
|
||||
"Charm": {
|
||||
"$path": "packages/charm"
|
||||
"packages": {
|
||||
"$className": "Folder",
|
||||
"vide": {
|
||||
"$path": "node_modules/@rbxts/vide"
|
||||
},
|
||||
"CharmSync": {
|
||||
"$path": "packages/charm-sync"
|
||||
"charm": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/charm/src" },
|
||||
"test": { "$path": "packages/charm/test" }
|
||||
},
|
||||
"ReactCharm": {
|
||||
"$path": "packages/react"
|
||||
"deep-charm": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/deep-charm/src" },
|
||||
"test": { "$path": "packages/deep-charm/test" },
|
||||
"Charm": { "$path": "packages/deep-charm/Charm.luau" }
|
||||
},
|
||||
"VideCharm": {
|
||||
"$path": "packages/vide"
|
||||
"charm-sync": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/charm-sync/src" },
|
||||
"test": { "$path": "packages/charm-sync/test" },
|
||||
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
|
||||
},
|
||||
"react-charm": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/react-charm/src" },
|
||||
"test": { "$path": "packages/react-charm/test" },
|
||||
"React": { "$path": "packages/react-charm/React.luau" },
|
||||
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
|
||||
"Charm": { "$path": "packages/react-charm/Charm.luau" }
|
||||
},
|
||||
"vide-charm": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/vide-charm/src" },
|
||||
"test": { "$path": "packages/vide-charm/test" },
|
||||
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
|
||||
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerScriptService": {
|
||||
"$className": "ServerScriptService",
|
||||
"Tests": {
|
||||
},
|
||||
"@rbxts-js": {
|
||||
"$path": "node_modules/@rbxts-js"
|
||||
},
|
||||
"tests": {
|
||||
"$path": "tests"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
eslint.config.js
Normal file
20
eslint.config.js
Normal 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,
|
||||
);
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# This file is automatically @generated by Wally.
|
||||
# It is not intended for manual editing.
|
||||
registry = "test"
|
||||
|
||||
[[package]]
|
||||
name = "centau/vide"
|
||||
version = "0.3.1"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/boolean"
|
||||
version = "1.2.7"
|
||||
dependencies = [["number", "jsdotlua/number@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/collections"
|
||||
version = "1.2.7"
|
||||
dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/console"
|
||||
version = "1.2.7"
|
||||
dependencies = [["collections", "jsdotlua/collections@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/es7-types"
|
||||
version = "1.2.7"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/instance-of"
|
||||
version = "1.2.7"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/luau-polyfill"
|
||||
version = "1.2.7"
|
||||
dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/math"
|
||||
version = "1.2.7"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/number"
|
||||
version = "1.2.7"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/promise"
|
||||
version = "3.5.2"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/react"
|
||||
version = "17.2.1"
|
||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/react-reconciler"
|
||||
version = "17.2.1"
|
||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/react-roblox"
|
||||
version = "17.2.1"
|
||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/scheduler"
|
||||
version = "17.2.1"
|
||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/shared"
|
||||
version = "17.2.1"
|
||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/string"
|
||||
version = "1.2.7"
|
||||
dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/symbol-luau"
|
||||
version = "1.0.1"
|
||||
dependencies = []
|
||||
|
||||
[[package]]
|
||||
name = "jsdotlua/timers"
|
||||
version = "1.2.7"
|
||||
dependencies = [["collections", "jsdotlua/collections@1.2.7"]]
|
||||
|
||||
[[package]]
|
||||
name = "littensy/charm"
|
||||
version = "0.1.0"
|
||||
dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]]
|
||||
|
||||
[[package]]
|
||||
name = "roblox/testez"
|
||||
version = "0.4.1"
|
||||
dependencies = []
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[package]
|
||||
name = "littensy/charm"
|
||||
version = "0.1.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
[dev-dependencies]
|
||||
React = "jsdotlua/react@17.2.0"
|
||||
ReactRoblox = "jsdotlua/react-roblox@17.2.0"
|
||||
TestEZ = "roblox/testez@0.4.1"
|
||||
Vide = "centau/vide@0.3.1"
|
||||
33
package.json
33
package.json
|
|
@ -1,24 +1,29 @@
|
|||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "sh bin/build.sh",
|
||||
"check": "sh bin/check.sh",
|
||||
"wally": "sh bin/install.sh",
|
||||
"test": "lute run test",
|
||||
"build": "lute run build",
|
||||
"check": "lute run check",
|
||||
"publish:npm": "pnpm publish -r --access public",
|
||||
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
|
||||
},
|
||||
"packageManager": "pnpm@12.8.1",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@rbxts/compiler-types": "3.0.0-types.0",
|
||||
"@rbxts/types": "^1.0.811",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-roblox-ts": "^0.0.36",
|
||||
"prettier": "^3.3.3",
|
||||
"@rbxts/react": "17.3.7-ts.1",
|
||||
"@rbxts/react-roblox": "17.3.7-ts.1",
|
||||
"@rbxts/types": "^1.0.913",
|
||||
"@rbxts/vide": "^0.6.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-roblox-ts": "^1.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"roblox-ts": "^3.0.0",
|
||||
"typescript": "~5.5.4"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
packages/charm-sync/Charm.luau
Normal file
9
packages/charm-sync/Charm.luau
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
if not script then
|
||||
return require("../charm/src")
|
||||
end
|
||||
|
||||
local Charm = require(script.Parent.Parent.charm.src)
|
||||
|
||||
export type Atom<T> = Charm.Atom<T>
|
||||
|
||||
return Charm
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "CharmSync",
|
||||
"name": "charm-sync",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
{
|
||||
"name": "@rbxts/charm-sync",
|
||||
"version": "0.3.0",
|
||||
"description": "Sync server state with clients using Charm",
|
||||
"repository": "littensy/charm",
|
||||
"version": "0.4.1",
|
||||
"description": "Simple server-to-client sync for Charm",
|
||||
"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.md . && cp ../../README.md .",
|
||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,112 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
local Charm = require("../Charm")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
|
||||
type SyncPayload = types.SyncPayload
|
||||
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
|
||||
|
||||
type ClientOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the server.
|
||||
]=]
|
||||
atoms: AtomMap,
|
||||
--[=[
|
||||
Whether to ignore patches sent before the client has been hydrated.
|
||||
Default is `true`.
|
||||
]=]
|
||||
ignoreUnhydrated: boolean?,
|
||||
}
|
||||
local trackedSignals: { [string]: (any) -> any } = {}
|
||||
local trackedReactives: { [string]: ReactiveProxy } = {}
|
||||
|
||||
type ClientSyncer = {
|
||||
--[=[
|
||||
Applies a patch or initializes the state of the atoms with the given
|
||||
payload from the server.
|
||||
|
||||
@param ...payloads The patches or hydration payloads to apply.
|
||||
]=]
|
||||
sync: (self: ClientSyncer, ...SyncPayload) -> (),
|
||||
}
|
||||
|
||||
--[=[
|
||||
Creates a `ClientSyncer` object that receives patches from the server and
|
||||
applies them to the local state.
|
||||
|
||||
@param options The atoms to synchronize with the server.
|
||||
@return A `ClientSyncer` object.
|
||||
]=]
|
||||
local function client(options: ClientOptions): ClientSyncer
|
||||
local atoms = options.atoms
|
||||
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
|
||||
|
||||
local self = {} :: ClientSyncer
|
||||
local hydrated = false
|
||||
|
||||
local function hydrate(state: { [string | number]: any })
|
||||
hydrated = true
|
||||
|
||||
for key, value in next, state do
|
||||
local atom = atoms[key]
|
||||
atom(value)
|
||||
end
|
||||
local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
|
||||
local target = reactive.__target
|
||||
for key, value in source do
|
||||
target[key] = value
|
||||
end
|
||||
|
||||
local function apply(data: { [string | number]: any })
|
||||
local target = {}
|
||||
|
||||
for key, atom in next, atoms do
|
||||
target[key] = atom()
|
||||
end
|
||||
|
||||
target = patch.apply(target, data)
|
||||
|
||||
for key, atom in next, atoms do
|
||||
atom(target[key])
|
||||
end
|
||||
end
|
||||
|
||||
function self:sync(...)
|
||||
for index = 1, select("#", ...) do
|
||||
local payload: SyncPayload = select(index, ...)
|
||||
|
||||
Charm.batch(function()
|
||||
if payload.type == "init" then
|
||||
hydrate(payload.data)
|
||||
elseif not ignoreUnhydrated or hydrated then
|
||||
apply(payload.data)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
Charm.trigger(reactive.__track)
|
||||
end
|
||||
|
||||
return client
|
||||
--[[
|
||||
Registers a map of setters to the state with the corresponding keys on the
|
||||
server. When the server sends updates, these setters will be called with
|
||||
the new values.
|
||||
|
||||
Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
Note that `server.addSignalsToClient` still requires some way to get the
|
||||
state.
|
||||
|
||||
@param signals A map of setter functions to sync with the server.
|
||||
]]
|
||||
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
|
||||
|
||||
--[[
|
||||
Unregisters from server state updates for the given keys. The signals
|
||||
will retain their current values, but will no longer receive updates
|
||||
from the server.
|
||||
]]
|
||||
local function removeSignals(...: string)
|
||||
for index = 1, select("#", ...) do
|
||||
local key = select(index, ...)
|
||||
trackedSignals[key] = nil
|
||||
trackedReactives[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes from all server state updates.
|
||||
]]
|
||||
local function removeAllSignals()
|
||||
table.clear(trackedSignals)
|
||||
table.clear(trackedReactives)
|
||||
end
|
||||
|
||||
local function applyInitPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
if trackedSignals[key] then
|
||||
trackedSignals[key](value)
|
||||
elseif trackedReactives[key] then
|
||||
shallowMergeReactive(trackedReactives[key], value)
|
||||
else
|
||||
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyPatchPayload(data: { [string]: any })
|
||||
for key, diffs in data do
|
||||
local trackedSignal = trackedSignals[key]
|
||||
local trackedReactive = trackedReactives[key]
|
||||
|
||||
if trackedSignal then
|
||||
trackedSignal(function(value)
|
||||
return patch.apply(value, diffs)
|
||||
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
|
||||
|
||||
--[[
|
||||
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 applyPatches(payloads: { SyncPayload })
|
||||
for _, payload in payloads do
|
||||
if payload.type == "init" then
|
||||
Charm.batch(applyInitPayload, payload.data)
|
||||
elseif payload.type == "patch" then
|
||||
Charm.batch(applyPatchPayload, payload.data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
addSignals = addSignals,
|
||||
removeSignals = removeSignals,
|
||||
removeAllSignals = removeAllSignals,
|
||||
patch = applyPatches,
|
||||
}
|
||||
|
|
|
|||
31
packages/charm-sync/src/config.luau
Normal file
31
packages/charm-sync/src/config.luau
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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
|
||||
negative value to disable automatic syncing.
|
||||
]]
|
||||
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
|
||||
times per frame, where each individual change is important. Defaults
|
||||
to `false` for performance reasons.
|
||||
]]
|
||||
preserveHistory = false,
|
||||
--[[
|
||||
When `true`, Charm will modify sparse arrays to prevent data loss from
|
||||
remote event limitations. When an unsafe array is detected, numeric
|
||||
indices will be converted to string keys so that the array can be sent
|
||||
safely and then converted back to an array on the receiving end.
|
||||
|
||||
This option should be disabled if your network library uses a custom
|
||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
]]
|
||||
fixArrays = true,
|
||||
--[[
|
||||
When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
tables will emit a warning. Only checked in strict mode and if
|
||||
`fixArrays` is enabled. Defaults to `true`.
|
||||
]]
|
||||
validatePatches = true,
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
|
||||
type NestedAtomMap = {
|
||||
[string]: NestedAtomMap | () -> (),
|
||||
}
|
||||
|
||||
local function flatten(atoms: NestedAtomMap): AtomMap
|
||||
local result: AtomMap = {}
|
||||
|
||||
local function visit(node: NestedAtomMap, path: string)
|
||||
for key, value in node do
|
||||
local location = if path == "" then key else path .. "/" .. key
|
||||
|
||||
if type(value) == "table" then
|
||||
visit(value, location)
|
||||
else
|
||||
result[location] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
visit(atoms, "")
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
return flatten
|
||||
462
packages/charm-sync/src/index.d.ts
vendored
462
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1,214 +1,292 @@
|
|||
import { Atom, Selector } from "@rbxts/charm";
|
||||
|
||||
export = CharmSync;
|
||||
export as namespace CharmSync;
|
||||
|
||||
type Cleanup = () => void;
|
||||
import { Atom, Setter } from "@rbxts/charm";
|
||||
|
||||
type Key = string | number | symbol;
|
||||
|
||||
declare namespace CharmSync {
|
||||
type AtomMap = Record<string, Atom<any>>;
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
export interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type SelectorMap = Record<string, Selector<any>>;
|
||||
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
type ReactiveObject = { [key: Key]: 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.
|
||||
*/
|
||||
export type StateOfMap<T> = {
|
||||
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
|
||||
};
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `isNone` function.
|
||||
*/
|
||||
// TODO: Optimize type, this looks overly complex
|
||||
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? FixArrays extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
|
||||
: readonly (SyncPatch<T, FixArrays> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
|
||||
: State;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
|
||||
| { type: "init"; data: StateOfMap<Getters> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
||||
|
||||
/**
|
||||
* Global configuration options that affect the behavior of Charm Sync.
|
||||
*/
|
||||
export const config: {
|
||||
/**
|
||||
* @deprecated Use `SelectorMap` instead.
|
||||
*/
|
||||
type MoleculeMap = SelectorMap;
|
||||
|
||||
/**
|
||||
* Infers the type of the return values produced by a map of functions.
|
||||
*/
|
||||
type StateOfMap<T> = {
|
||||
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
/**
|
||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
||||
* applies them to the local state.
|
||||
*
|
||||
* @client
|
||||
* @param options The atoms to synchronize with the server.
|
||||
* @returns A `ClientSyncer` object.
|
||||
*/
|
||||
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
|
||||
|
||||
/**
|
||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
||||
* hydrates the client's state.
|
||||
* 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
|
||||
* negative value to disable automatic syncing.
|
||||
*
|
||||
* @server
|
||||
* @param options The atoms to synchronize with the client.
|
||||
* @returns A `ServerSyncer` object.
|
||||
*/
|
||||
function server<Selectors extends SelectorMap, Serialize extends boolean = true>(
|
||||
options: ServerOptions<Selectors, Serialize>,
|
||||
): ServerSyncer<Selectors, Serialize>;
|
||||
|
||||
interval: number;
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple
|
||||
* times per frame, where each individual change is important. Defaults
|
||||
* to `false` for performance reasons.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
* @server
|
||||
*/
|
||||
function isNone(value: unknown): value is None;
|
||||
|
||||
interface NestedAtomMap {
|
||||
readonly [key: string]: NestedAtomMap | Atom<any>;
|
||||
}
|
||||
|
||||
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
|
||||
? Name
|
||||
: Name extends string
|
||||
? Path extends string
|
||||
? `${Path}/${Name}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
|
||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
||||
|
||||
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
|
||||
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
|
||||
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
|
||||
: Atoms[Name] extends NestedAtomMap
|
||||
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
preserveHistory: boolean;
|
||||
/**
|
||||
* Flattens a nested atom map into a single object with slash-separated
|
||||
* keys. Useful for recursively collecting atoms returned by modules.
|
||||
* When `true`, Charm will modify sparse arrays to prevent data loss from
|
||||
* remote event limitations. When an unsafe array is detected, numeric
|
||||
* indices will be converted to string keys so that the array can be sent
|
||||
* safely and then converted back to an array on the receiving end.
|
||||
*
|
||||
* @param atoms The nested atom map to flatten.
|
||||
* @return A flattened atom map.
|
||||
*/
|
||||
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that should not be made partial in patches.
|
||||
*/
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `sync.isNone` function.
|
||||
* @server
|
||||
*/
|
||||
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? Serialize extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
||||
: State;
|
||||
fixArrays: boolean;
|
||||
/**
|
||||
* When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
* tables will emit a warning. Only checked in strict mode and if
|
||||
* `fixArrays` is enabled. Defaults to `true`.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
validatePatches: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @client
|
||||
*/
|
||||
export namespace client {
|
||||
/**
|
||||
* Registers a map of setters to the state with the corresponding keys on the
|
||||
* server. When the server sends updates, these setters will be called with
|
||||
* the new values.
|
||||
*
|
||||
* Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
* Note that `server.addSignalsToClient` still requires some way to get the
|
||||
* state.
|
||||
*
|
||||
* @param signals A map of setter functions to sync with the server.
|
||||
*/
|
||||
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
* Unregisters from server state updates for the given keys. The signals
|
||||
* will retain their current values, but will no longer receive updates
|
||||
* from the server.
|
||||
*/
|
||||
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> =
|
||||
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
|
||||
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
||||
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
|
||||
...keys: (keyof Signals)[]
|
||||
): void;
|
||||
|
||||
interface ClientOptions<Atoms extends AtomMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the server.
|
||||
*/
|
||||
atoms: Atoms;
|
||||
/**
|
||||
* Whether to ignore patches sent before the client has been hydrated.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
ignoreUnhydrated?: boolean;
|
||||
}
|
||||
/**
|
||||
* Unsubscribes from all server state updates.
|
||||
*/
|
||||
export function removeAllSignals(): void;
|
||||
|
||||
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> {
|
||||
/**
|
||||
* The atoms to synchronize with the client.
|
||||
*/
|
||||
atoms: Selectors;
|
||||
/**
|
||||
* 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
|
||||
* negative value to disable automatic syncing.
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple times
|
||||
* per frame, where each individual change is important. Defaults to `false`.
|
||||
*
|
||||
* If `true`, the broadcaster will send a list of payloads to the client
|
||||
* instead of a single payload. The client will apply each payload in order
|
||||
* to reconstruct the state's changes over time.
|
||||
*/
|
||||
preserveHistory?: boolean;
|
||||
/**
|
||||
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
* to address remote event argument limitations. Defaults to `true`.
|
||||
*
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*/
|
||||
autoSerialize?: Serialize;
|
||||
}
|
||||
|
||||
interface ClientSyncer<Atoms extends AtomMap> {
|
||||
/**
|
||||
* Applies a patch or initializes the state of the atoms with the given
|
||||
* payload from the server.
|
||||
*
|
||||
* @param payloads The patches or hydration payloads to apply.
|
||||
*/
|
||||
sync(...payloads: SyncPayload<Atoms, boolean>[]): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
|
||||
/**
|
||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
* the client whenever the state changes. When a change occurs, the `callback`
|
||||
* is called with the player and the payloads to send.
|
||||
*
|
||||
* Note that a `payload` object should not be mutated. If you need to modify
|
||||
* a payload, apply the changes to a copy of the object.
|
||||
*
|
||||
* @param callback The function to call when the state changes.
|
||||
* @returns A cleanup function that unsubscribes all listeners.
|
||||
*/
|
||||
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors, Serialize>[]) => void): Cleanup;
|
||||
/**
|
||||
* Hydrates the client's state with the server's state. This should be
|
||||
* called when a player joins the game and requires the server's state.
|
||||
*
|
||||
* @param player The player to hydrate.
|
||||
*/
|
||||
hydrate(player: Player): void;
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||
payloads: SyncPayload<Getters, FixArrays>[],
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @server
|
||||
*/
|
||||
export namespace server {
|
||||
/**
|
||||
* 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, computed signals, 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.
|
||||
*/
|
||||
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||
client: Player,
|
||||
signals: Signals,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
* from specific keys, use `removeSignalsFromClient` instead.
|
||||
*
|
||||
* @param client The client receiving state updates.
|
||||
*/
|
||||
export function removeClient(client: Player): void;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||
client: Player,
|
||||
...keys: (keyof Signals)[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Uses the callback to send state updates to clients that are subscribed
|
||||
* to state changes. Starts automatically sending updates at the interval
|
||||
* specified in `config.interval`.
|
||||
*
|
||||
* @param onSync Called when sending patches to a client.
|
||||
*/
|
||||
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Stops syncing state updates to clients at the automatic interval.
|
||||
* Flushing can still be performed manually via `flush`.
|
||||
*/
|
||||
export function disconnect(): void;
|
||||
|
||||
/**
|
||||
* Immediately sends pending updates to all clients. Normally, updates are
|
||||
* sent at the interval specified in `config.interval`, but this function
|
||||
* can be used to force an immediate sync.
|
||||
*/
|
||||
export function flush(): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes all clients, stops tracking all state, and disconnects
|
||||
* from the automatic sync interval.
|
||||
*/
|
||||
export function reset(): void;
|
||||
}
|
||||
|
||||
export namespace patch {
|
||||
/**
|
||||
* Converts `nil` values to the special `None` symbol. This is useful when
|
||||
* preparing data for serialization, since `nil` can represent both the
|
||||
* absence of a value and the removal of a value in patches.
|
||||
*/
|
||||
export function nilToNone<T>(value: T | undefined): T | None;
|
||||
|
||||
/**
|
||||
* Compares two states and returns a patch that can be applied to the
|
||||
* current state to produce the next state.
|
||||
*
|
||||
* @param currentState The current state.
|
||||
* @param nextState The next state.
|
||||
* @returns A patch that can be applied to the current state.
|
||||
*/
|
||||
export function diff<State, FixArrays extends boolean = true>(
|
||||
currentState: State,
|
||||
nextState: State,
|
||||
): SyncPatch<State, FixArrays>;
|
||||
|
||||
/**
|
||||
* Applies a patch to a state and returns the resulting value.
|
||||
*
|
||||
* @param currentState The current state.
|
||||
* @param statePatch The patches to apply.
|
||||
* @returns The new state with the patch applied.
|
||||
*/
|
||||
export function applyImmutable<State, FixArrays extends boolean = true>(
|
||||
currentState: State,
|
||||
statePatch: SyncPatch<State, FixArrays>,
|
||||
): State;
|
||||
}
|
||||
|
||||
type DeepFlattenImpl<T, Prefix extends string = ""> = {
|
||||
[K in keyof T]: T[K] extends DataType | Callback
|
||||
? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
|
||||
: T[K] extends object
|
||||
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
|
||||
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
|
||||
}[keyof T];
|
||||
|
||||
type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
|
||||
|
||||
/**
|
||||
* Flattens a nested table into a single table with slash-separated keys.
|
||||
* Useful for recursively collecting atoms returned by modules.
|
||||
*
|
||||
* @param input A nested table.
|
||||
* @return A flattened table with slash-separated keys.
|
||||
*/
|
||||
export function flatten<T>(input: T): DeepFlatten<T>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
*/
|
||||
export function isNone(value: unknown): value is None;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* @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>;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
local client = require(script.client)
|
||||
local flatten = require(script.flatten)
|
||||
local patch = require(script.patch)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
local client = require("@self/client")
|
||||
local config = require("@self/config")
|
||||
local patch = require("@self/patch")
|
||||
local server = require("@self/server")
|
||||
local types = require("@self/types")
|
||||
local utils = require("@self/utils")
|
||||
|
||||
export type None = types.None
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
--[=[
|
||||
Synchronizes state between the client and server. The server sends patches
|
||||
to the client, which applies them to its local state.
|
||||
]=]
|
||||
return {
|
||||
config = config,
|
||||
client = client,
|
||||
server = server,
|
||||
flatten = flatten,
|
||||
patch = patch,
|
||||
isNone = patch.isNone,
|
||||
flatten = utils.flatten,
|
||||
signalToAtom = utils.signalToAtom,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
local RunService = game:GetService("RunService")
|
||||
|
||||
--[=[
|
||||
Schedules a function to be called at a fixed interval of `seconds`.
|
||||
|
||||
@param callback The function to call.
|
||||
@param seconds The interval in seconds.
|
||||
@return A function that cancels the interval.
|
||||
]=]
|
||||
local function interval(callback: () -> (), seconds: number): () -> ()
|
||||
if seconds < 0 then
|
||||
return function() end
|
||||
end
|
||||
|
||||
local clock = 0
|
||||
|
||||
local connection = RunService.Heartbeat:Connect(function(delta)
|
||||
clock += delta
|
||||
if clock >= seconds then
|
||||
clock = 0
|
||||
callback()
|
||||
end
|
||||
end)
|
||||
|
||||
return function()
|
||||
connection:Disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
return interval
|
||||
|
|
@ -1,28 +1,45 @@
|
|||
local validate = require(script.Parent.validate)
|
||||
local Charm = require("../Charm")
|
||||
local config = require("./config")
|
||||
local types = require("./types")
|
||||
local validate = require("./validate")
|
||||
|
||||
local __DEV__ = _G.__DEV__
|
||||
local flags = Charm.flags
|
||||
|
||||
--[=[
|
||||
A special symbol that denotes the absence of a value. Used to represent
|
||||
deleted values in patches.
|
||||
]=]
|
||||
local None = { __none = "__none" }
|
||||
export type None = types.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.
|
||||
|
||||
@param value The value to check.
|
||||
@return `true` if the value is `None`, otherwise `false`.
|
||||
]]
|
||||
local function isNone(value: any): boolean
|
||||
return type(value) == "table" and value.__none == "__none"
|
||||
end
|
||||
|
||||
--[=[
|
||||
--[[
|
||||
Converts `nil` values to the special `None` symbol. This is useful when
|
||||
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
|
||||
return if value == nil then None else value
|
||||
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
|
||||
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
|
||||
The `patch` functions will convert these dictionaries back into arrays as
|
||||
long as the target array is not sparse. This function ensures that the diff
|
||||
function produces a patch that can be correctly applied.
|
||||
]=]
|
||||
local function stringifySparseArray(object: { any }): { [string | number]: any }
|
||||
]]
|
||||
local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
|
||||
local maxn = table.maxn(object)
|
||||
|
||||
if maxn == 0 or maxn == #object then
|
||||
|
|
@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
|
|||
|
||||
local dictionary = {}
|
||||
|
||||
for index, value in next, object do
|
||||
for index, value in object do
|
||||
dictionary[tostring(index)] = value
|
||||
end
|
||||
|
||||
return dictionary
|
||||
end
|
||||
|
||||
--[=[
|
||||
Compares two states and returns a patch that can be applied to the previous
|
||||
state to produce the next state.
|
||||
local function diff(oldState: any, newState: any): any
|
||||
if oldState == newState then
|
||||
return nil
|
||||
elseif newState == nil then
|
||||
return None
|
||||
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
|
||||
return newState
|
||||
end
|
||||
|
||||
@param prevState The previous state.
|
||||
@param nextState The next state.
|
||||
@param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
|
||||
@returns A patch that can be applied to the previous state.
|
||||
]=]
|
||||
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
|
||||
serialize = serialize ~= false
|
||||
local diffs = table.clone(newState)
|
||||
|
||||
local patches = table.clone(nextState)
|
||||
for key, oldValue in oldState do
|
||||
diffs[key] = diff(oldValue, newState[key])
|
||||
end
|
||||
|
||||
for key, previous in next, prevState do
|
||||
local current = nextState[key]
|
||||
if next(diffs) == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
if previous == current then
|
||||
patches[key] = nil
|
||||
elseif current == nil then
|
||||
patches[key] = None
|
||||
elseif type(previous) == "table" and type(current) == "table" then
|
||||
patches[key] = diff(previous, current, serialize)
|
||||
if config.fixArrays then
|
||||
-- Convert sparse array patches that could be misserialized
|
||||
if oldState[1] ~= nil or newState[1] ~= nil then
|
||||
diffs = stringifySparseArray(diffs)
|
||||
end
|
||||
if flags.strict and config.validatePatches then
|
||||
validate(diffs)
|
||||
end
|
||||
end
|
||||
|
||||
-- Coerce sparse array patches into dictionaries
|
||||
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
|
||||
patches = stringifySparseArray(patches)
|
||||
end
|
||||
|
||||
if serialize and __DEV__ then
|
||||
for key, value in next, patches do
|
||||
validate(value, key)
|
||||
end
|
||||
end
|
||||
|
||||
return patches
|
||||
return diffs
|
||||
end
|
||||
|
||||
--[=[
|
||||
Applies a patch to a state and returns the next state.
|
||||
|
||||
@param state The current state.
|
||||
@param patches The patches to apply.
|
||||
@returns The next state.
|
||||
]=]
|
||||
local function apply(state: any, patches: any): any
|
||||
if type(patches) == "table" and patches.__none == "__none" then
|
||||
local function apply(oldState: any, diffs: any): any
|
||||
if isNone(diffs) then
|
||||
return nil
|
||||
elseif type(state) ~= "table" or type(patches) ~= "table" then
|
||||
return patches
|
||||
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
|
||||
return diffs
|
||||
end
|
||||
|
||||
local nextState = table.clone(state)
|
||||
local stateIsArray = state[1] ~= nil
|
||||
local newState = table.clone(oldState)
|
||||
local stateIsArray = oldState[1] ~= nil
|
||||
|
||||
for key, patch in next, patches do
|
||||
for key, value in diffs do
|
||||
-- Diff-checking an array produces a sparse array, which will not be
|
||||
-- preserved when converted to JSON. To prevent this, we turn string
|
||||
-- keys back into numeric keys.
|
||||
if stateIsArray and type(key) == "string" then
|
||||
key = tonumber(key) or key
|
||||
end
|
||||
|
||||
nextState[key] = apply(nextState[key], patch)
|
||||
newState[key] = apply(newState[key], value)
|
||||
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
|
||||
|
||||
return {
|
||||
isNone = isNone,
|
||||
nilToNone = nilToNone,
|
||||
diff = diff,
|
||||
apply = apply,
|
||||
applyMutable = applyMutable,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,267 +1,416 @@
|
|||
local Players = game:GetService("Players")
|
||||
local Charm = require("../Charm")
|
||||
local config = require("./config")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
local utils = require("./utils")
|
||||
|
||||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local interval = require(script.Parent.interval)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type ServerOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the client.
|
||||
]=]
|
||||
atoms: AtomMap,
|
||||
--[=[
|
||||
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
|
||||
negative value to disable automatic syncing.
|
||||
]=]
|
||||
interval: number?,
|
||||
--[=[
|
||||
Whether the history of state changes since the client's last update
|
||||
should be preserved. This is useful for values that change multiple times
|
||||
per frame, where each individual change is important. Defaults to `false`.
|
||||
]=]
|
||||
preserveHistory: boolean?,
|
||||
--[=[
|
||||
When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
to address remote event argument limitations. Defaults to `true`.
|
||||
type SyncStatus = number
|
||||
|
||||
This option should be disabled if your network library uses a custom
|
||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
]=]
|
||||
autoSerialize: boolean?,
|
||||
type ClientKeys = { [string]: SyncStatus }
|
||||
|
||||
type TrackedState<T = any> = {
|
||||
identifier: unknown,
|
||||
currentValue: T,
|
||||
lastSyncedValue: T,
|
||||
isReactive: boolean,
|
||||
dispose: () -> (),
|
||||
}
|
||||
|
||||
type ServerSyncer = {
|
||||
--[=[
|
||||
Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
the client whenever the state changes. When a change occurs, the `callback`
|
||||
is called with the player and the payload to send.
|
||||
|
||||
Note that the `payload` object should not be mutated. If you need to
|
||||
modify the payload, apply the changes to a copy of the object.
|
||||
|
||||
@param callback The function to call when the state changes.
|
||||
@return A cleanup function that unsubscribes all listeners.
|
||||
]=]
|
||||
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
|
||||
--[=[
|
||||
Hydrates the client's state with the server's state. This should be
|
||||
called when a player joins the game and requires the server's state.
|
||||
|
||||
@param player The player to hydrate.
|
||||
]=]
|
||||
hydrate: (self: ServerSyncer, player: Player) -> (),
|
||||
--[=[
|
||||
@deprecated For internal use only.
|
||||
]=]
|
||||
_sendPatch: (self: ServerSyncer, player: Player) -> (),
|
||||
type StateUpdate = {
|
||||
key: string,
|
||||
newValue: any,
|
||||
oldValue: any,
|
||||
}
|
||||
|
||||
--[=[
|
||||
Creates a `ServerSyncer` object that sends patches to the client and
|
||||
hydrates the client's state.
|
||||
|
||||
@param options The atoms to synchronize with the client.
|
||||
@return A `ServerSyncer` object.
|
||||
]=]
|
||||
local function server(options: ServerOptions): ServerSyncer
|
||||
local atoms = options.atoms
|
||||
local autoSerialize = options.autoSerialize ~= false
|
||||
local preserveHistory = options.preserveHistory
|
||||
local syncRate = options.interval or 0
|
||||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||
|
||||
local self = {} :: ServerSyncer
|
||||
local sync: (player: Player, payload: SyncPayload) -> ()
|
||||
local trackedStates: { [string]: TrackedState } = {}
|
||||
local globalUpdates: { StateUpdate } = {}
|
||||
local clients: { [any]: ClientKeys } = {}
|
||||
|
||||
local function createSnapshot()
|
||||
local snapshot = {}
|
||||
local connection: RBXScriptConnection?
|
||||
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
|
||||
|
||||
for key, atom in next, atoms do
|
||||
snapshot[key] = atom()
|
||||
--[[
|
||||
Manually converts a reactive proxy to its raw representation to potentially
|
||||
support custom implementations of reactive proxies.
|
||||
]]
|
||||
local function toRaw<T>(value: T): T
|
||||
local proxy = value :: types.ReactiveProxy<T>
|
||||
if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
|
||||
proxy.__track()
|
||||
return toRaw(proxy.__target)
|
||||
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)
|
||||
end
|
||||
|
||||
return snapshot
|
||||
return
|
||||
end
|
||||
|
||||
-- Send the initial state to a player when they join the server.
|
||||
function self:hydrate(player)
|
||||
assert(sync, "connect() must be called before hydrate()")
|
||||
local trackedState: TrackedState<T>
|
||||
local initialValue = getter()
|
||||
local rawInitialValue = toRaw(initialValue)
|
||||
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
|
||||
|
||||
sync(player, {
|
||||
type = "init",
|
||||
data = createSnapshot(),
|
||||
})
|
||||
end
|
||||
local dispose = Charm.untracked(Charm.effect, function()
|
||||
local newValue = getter()
|
||||
local rawValue = toRaw(newValue)
|
||||
local isReactive = newValue ~= rawValue
|
||||
|
||||
-- If the history of state changes should be preserved, keep a list of
|
||||
-- previous snapshots and diff-check each one from the last.
|
||||
if preserveHistory then
|
||||
local snapshots = { createSnapshot() }
|
||||
local changed = false
|
||||
|
||||
function self:connect(callback)
|
||||
local subscriptions = {}
|
||||
|
||||
sync = callback
|
||||
|
||||
local function pushSnapshot(key: string | number, current: any, previous: any)
|
||||
local lastSnapshot = snapshots[#snapshots]
|
||||
local previousSnapshot = snapshots[#snapshots - 1]
|
||||
|
||||
-- Optimize snapshots by updating the most recent snapshot if the
|
||||
-- previous and current values are the same, since this allows us
|
||||
-- to group multiple changes into a single snapshot.
|
||||
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
|
||||
lastSnapshot[key] = current
|
||||
else
|
||||
local nextSnapshot = table.clone(lastSnapshot)
|
||||
nextSnapshot[key] = current
|
||||
table.insert(snapshots, nextSnapshot)
|
||||
end
|
||||
end
|
||||
|
||||
-- Populate the snapshot with the initial state of each atom.
|
||||
-- Subscribe to each atom and update the state when it changes.
|
||||
for key, atom in next, atoms do
|
||||
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
|
||||
pushSnapshot(key, current, previous)
|
||||
changed = true
|
||||
end)
|
||||
end
|
||||
|
||||
local disconnect = interval(function()
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
local lastSnapshot
|
||||
|
||||
for index, snapshot in next, snapshots do
|
||||
lastSnapshot = snapshot
|
||||
|
||||
if index == 1 then
|
||||
continue
|
||||
end
|
||||
|
||||
table.insert(payloads, {
|
||||
type = "patch",
|
||||
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
|
||||
})
|
||||
end
|
||||
|
||||
snapshots = { lastSnapshot }
|
||||
changed = false
|
||||
|
||||
for _, player in next, Players:GetPlayers() do
|
||||
callback(player, unpack(payloads))
|
||||
end
|
||||
end, syncRate)
|
||||
|
||||
return function()
|
||||
disconnect()
|
||||
|
||||
for _, unsubscribe in next, subscriptions do
|
||||
unsubscribe()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function self:_sendPatch(player: Player)
|
||||
assert(sync, "connect() must be called before _sendPatch()")
|
||||
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
|
||||
for index, snapshot in next, snapshots do
|
||||
if index == 1 then
|
||||
continue
|
||||
end
|
||||
|
||||
table.insert(payloads, {
|
||||
type = "patch",
|
||||
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
|
||||
})
|
||||
end
|
||||
|
||||
snapshots = { snapshots[#snapshots] }
|
||||
changed = false
|
||||
|
||||
sync(player, unpack(payloads))
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
local previousSnapshot = createSnapshot()
|
||||
local currentSnapshot = table.clone(previousSnapshot)
|
||||
local changed = false
|
||||
|
||||
function self:connect(callback)
|
||||
local subscriptions = {}
|
||||
|
||||
sync = callback
|
||||
|
||||
-- Subscribe to each atom and update the state when one changes.
|
||||
for key, atom in next, atoms do
|
||||
subscriptions[key] = Charm.subscribe(atom, function(state)
|
||||
currentSnapshot[key] = state
|
||||
changed = true
|
||||
end)
|
||||
end
|
||||
|
||||
local disconnect = interval(function()
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
|
||||
}
|
||||
|
||||
previousSnapshot = table.clone(currentSnapshot)
|
||||
changed = false
|
||||
|
||||
for _, player in next, Players:GetPlayers() do
|
||||
callback(player, payload)
|
||||
end
|
||||
end, syncRate)
|
||||
|
||||
return function()
|
||||
disconnect()
|
||||
|
||||
for _, unsubscribe in next, subscriptions do
|
||||
unsubscribe()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function self:_sendPatch(player)
|
||||
assert(sync, "connect() must be called before _sendPatch()")
|
||||
|
||||
if not changed then
|
||||
if not trackedState then
|
||||
return
|
||||
end
|
||||
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
|
||||
}
|
||||
-- When preserving history, we need to keep track of previous values
|
||||
-- so we can create granular patches for each change.
|
||||
if config.preserveHistory then
|
||||
local prevSnapshot = snapshot
|
||||
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
|
||||
table.insert(globalUpdates, {
|
||||
key = key,
|
||||
newValue = snapshot,
|
||||
oldValue = prevSnapshot,
|
||||
})
|
||||
end
|
||||
|
||||
previousSnapshot = table.clone(currentSnapshot)
|
||||
changed = false
|
||||
trackedState.currentValue = rawValue
|
||||
trackedState.isReactive = isReactive
|
||||
end)
|
||||
|
||||
sync(player, payload)
|
||||
end
|
||||
trackedState = {
|
||||
identifier = identifier,
|
||||
currentValue = rawInitialValue,
|
||||
lastSyncedValue = snapshot,
|
||||
isReactive = initialValue ~= rawInitialValue,
|
||||
dispose = dispose,
|
||||
}
|
||||
|
||||
return self
|
||||
trackedStates[key] = trackedState
|
||||
end
|
||||
|
||||
return server
|
||||
local function stopTracking(key: string)
|
||||
local trackedState = trackedStates[key]
|
||||
if trackedState then
|
||||
trackedState.dispose()
|
||||
trackedStates[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function purgeUntracked()
|
||||
local active: { [string]: boolean } = {}
|
||||
|
||||
for _, clientKeys in clients do
|
||||
for key in clientKeys do
|
||||
active[key] = true
|
||||
end
|
||||
end
|
||||
|
||||
for key in trackedStates do
|
||||
if not active[key] then
|
||||
stopTracking(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function diffGlobalUpdateBuffer(): { any }
|
||||
local patchCount = #globalUpdates
|
||||
local patches = table.create(patchCount)
|
||||
|
||||
for index, update in globalUpdates do
|
||||
patches[index] = patch.diff(update.oldValue, update.newValue)
|
||||
end
|
||||
|
||||
return patches
|
||||
end
|
||||
|
||||
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
|
||||
local clientKeys = clients[client]
|
||||
|
||||
if not clientKeys then
|
||||
return nil
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
|
||||
-- Iterate over all global updates and see if the client is interested in
|
||||
-- any of them. If so, we create a patch payload for the update.
|
||||
for index, update in globalUpdates do
|
||||
local patchData = globalPatches[index]
|
||||
|
||||
if patchData == nil then
|
||||
continue
|
||||
end
|
||||
|
||||
local key = update.key
|
||||
local status = clientKeys[key]
|
||||
|
||||
if status == LISTENING_FOR_CHANGES then
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = { [key] = patchData },
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
elseif status == PENDING_INITIAL_STATE then
|
||||
local payload: SyncPayload = {
|
||||
type = "init",
|
||||
data = { [key] = update.newValue },
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
-- 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.
|
||||
local initData = {}
|
||||
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedStates[key]
|
||||
|
||||
if status == PENDING_INITIAL_STATE and trackedState then
|
||||
initData[key] = trackedState.currentValue
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
if next(initData) ~= nil then
|
||||
table.insert(payloads, 1, { type = "init" :: "init", data = initData })
|
||||
end
|
||||
|
||||
return payloads
|
||||
end
|
||||
|
||||
local function diffGlobalState(): { [string]: any }?
|
||||
local currentGlobalState: { [string]: any } = {}
|
||||
local previousGlobalState: { [string]: any } = {}
|
||||
|
||||
for key, trackedState in trackedStates do
|
||||
local currentValue = if trackedState.isReactive
|
||||
then utils.deepCopy(trackedState.currentValue)
|
||||
else trackedState.currentValue
|
||||
|
||||
currentGlobalState[key] = currentValue
|
||||
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||
trackedState.lastSyncedValue = currentValue
|
||||
end
|
||||
|
||||
return patch.diff(previousGlobalState, currentGlobalState)
|
||||
end
|
||||
|
||||
local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
|
||||
local clientKeys = clients[client]
|
||||
|
||||
if not clientKeys then
|
||||
return nil
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
local initData: { [string]: any } = {}
|
||||
local patchData: { [string]: any } = {}
|
||||
|
||||
-- Create patches for initialized signals that changed, and add signals
|
||||
-- that are still pending initial state to the init payload.
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedStates[key]
|
||||
|
||||
if status == LISTENING_FOR_CHANGES and globalPatch then
|
||||
patchData[key] = globalPatch[key]
|
||||
elseif status == PENDING_INITIAL_STATE then
|
||||
initData[key] = trackedState.currentValue
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
if next(initData) ~= nil then
|
||||
local payload: SyncPayload = {
|
||||
type = "init",
|
||||
data = initData,
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
end
|
||||
|
||||
if next(patchData) ~= nil then
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patchData,
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
end
|
||||
|
||||
return payloads
|
||||
end
|
||||
|
||||
local function startInterval(callback: () -> ())
|
||||
if not game then
|
||||
return
|
||||
end
|
||||
|
||||
if connection then
|
||||
connection:Disconnect()
|
||||
end
|
||||
|
||||
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
|
||||
clients[client] = {}
|
||||
end
|
||||
|
||||
local clientKeys = clients[client]
|
||||
|
||||
for key, signal in signals do
|
||||
if type(signal) == "function" then
|
||||
trackSignal(key, signal, signal)
|
||||
else
|
||||
trackSignal(key, function()
|
||||
return signal
|
||||
end, signal)
|
||||
end
|
||||
|
||||
if not clientKeys[key] then
|
||||
-- Mark the signal as pending initial state so we send its full
|
||||
-- value the next time we sync.
|
||||
clientKeys[key] = PENDING_INITIAL_STATE
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
from specific keys, use `unsubscribeClientFrom` instead.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
]]
|
||||
local function removeClient(client: any)
|
||||
clients[client] = nil
|
||||
purgeUntracked()
|
||||
end
|
||||
|
||||
--[[
|
||||
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.
|
||||
]]
|
||||
local function removeSignalsFromClient(client: any, ...: string)
|
||||
local clientKeys = clients[client]
|
||||
|
||||
if not clientKeys then
|
||||
return
|
||||
end
|
||||
|
||||
for index = 1, select("#", ...) do
|
||||
local key = select(index, ...)
|
||||
clientKeys[key] = nil
|
||||
end
|
||||
|
||||
if next(clientKeys) == nil then
|
||||
removeClient(client)
|
||||
else
|
||||
purgeUntracked()
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Immediately sends pending updates to all clients. Normally, updates are
|
||||
sent at the interval specified in `config.interval`, but this function
|
||||
can be used to force an immediate sync.
|
||||
]]
|
||||
local function flush()
|
||||
if not syncCallback then
|
||||
return
|
||||
end
|
||||
|
||||
local granular = config.preserveHistory
|
||||
local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
|
||||
|
||||
for client in clients do
|
||||
local payloads = if granular
|
||||
then selectFromGlobalPatchList(client, globalPatch :: { any })
|
||||
else selectFromGlobalPatch(client, globalPatch)
|
||||
|
||||
if payloads and payloads[1] then
|
||||
if task then
|
||||
task.spawn(syncCallback, client, payloads)
|
||||
else
|
||||
syncCallback(client, payloads)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.clear(globalUpdates)
|
||||
end
|
||||
|
||||
--[[
|
||||
Uses the callback to send state updates to clients that are subscribed
|
||||
to state changes. Starts automatically sending updates at the interval
|
||||
specified in `config.interval`.
|
||||
|
||||
@param onSync Called when sending patches to a client.
|
||||
]]
|
||||
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||
syncCallback = onSync
|
||||
startInterval(flush)
|
||||
end
|
||||
|
||||
--[[
|
||||
Stops syncing state updates to clients at the automatic interval.
|
||||
Flushing can still be performed manually via `flush`.
|
||||
]]
|
||||
local function disconnect()
|
||||
if connection then
|
||||
connection:Disconnect()
|
||||
connection = nil
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes all clients, stops tracking all state, and disconnects
|
||||
from the automatic sync interval.
|
||||
]]
|
||||
local function reset()
|
||||
table.clear(trackedStates)
|
||||
table.clear(clients)
|
||||
table.clear(globalUpdates)
|
||||
disconnect()
|
||||
end
|
||||
|
||||
return {
|
||||
addSignalsToClient = addSignalsToClient,
|
||||
removeClient = removeClient,
|
||||
removeSignalsFromClient = removeSignalsFromClient,
|
||||
connect = connect,
|
||||
disconnect = disconnect,
|
||||
flush = flush,
|
||||
reset = reset,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
--[=[
|
||||
--[[
|
||||
A payload that can be sent from the server to the client to synchronize
|
||||
state between the two.
|
||||
]=]
|
||||
export type SyncPayload = {
|
||||
type: "init",
|
||||
data: { [string | number]: any },
|
||||
} | {
|
||||
type: "patch",
|
||||
data: { [string | number]: any },
|
||||
}
|
||||
the states of the client's atoms with the server.
|
||||
]]
|
||||
export type SyncPayload =
|
||||
| {
|
||||
type: "init",
|
||||
data: { [string]: any },
|
||||
}
|
||||
| {
|
||||
type: "patch",
|
||||
data: { [string]: any },
|
||||
}
|
||||
|
||||
export type AtomMap = {
|
||||
[string | number]: Atom<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.
|
||||
]]
|
||||
export type None = { __none: "__none" }
|
||||
|
||||
return nil
|
||||
export type ReactiveProxy<T = any> = typeof(setmetatable(
|
||||
{} :: {
|
||||
__track: () -> (),
|
||||
__target: T,
|
||||
__signals: { [unknown]: () -> unknown },
|
||||
},
|
||||
{} :: { __index: T }
|
||||
))
|
||||
|
||||
return {}
|
||||
|
|
|
|||
65
packages/charm-sync/src/utils.luau
Normal file
65
packages/charm-sync/src/utils.luau
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
local Charm = require("../Charm")
|
||||
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
--[[
|
||||
Flattens a nested table into a single table with slash-separated keys.
|
||||
Useful for recursively collecting atoms returned by modules.
|
||||
|
||||
@param input A nested table.
|
||||
@return A flattened table with slash-separated keys.
|
||||
]]
|
||||
local function flatten<Flattened>(input: { [any]: any }): Flattened
|
||||
local result = {}
|
||||
|
||||
local function visit(node: { [any]: any }, path: string)
|
||||
for key, value in node do
|
||||
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
|
||||
|
||||
if type(value) == "table" then
|
||||
visit(value, location)
|
||||
else
|
||||
result[location] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
visit(input, "")
|
||||
|
||||
return result :: any
|
||||
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
|
||||
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.
|
||||
@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>
|
||||
return function(...)
|
||||
return if select("#", ...) == 0 then getter() else setter((...))
|
||||
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 {
|
||||
flatten = flatten,
|
||||
signalToAtom = signalToAtom,
|
||||
deepCopy = deepCopy,
|
||||
}
|
||||
|
|
@ -1,67 +1,46 @@
|
|||
local SAFE_KEYS = { string = true, number = true }
|
||||
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
||||
|
||||
local function isTableUnsafe(object: { [any]: any })
|
||||
local keyType = nil
|
||||
local objectSize = 0
|
||||
local firstKey = next(object)
|
||||
|
||||
-- All keys must have the same type
|
||||
for key in next, object do
|
||||
local currentType = type(key)
|
||||
|
||||
if not keyType and SAFE_KEYS[currentType] then
|
||||
keyType = currentType
|
||||
elseif keyType ~= currentType then
|
||||
return true
|
||||
end
|
||||
|
||||
objectSize += 1
|
||||
if firstKey == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
-- If there are more keys than the length of the array, it's an array with
|
||||
-- non-sequential keys.
|
||||
if objectSize > #object and keyType == "number" then
|
||||
local keyType = type(firstKey)
|
||||
local maxKey = 0
|
||||
|
||||
-- Keys must all be the same type
|
||||
for key in object do
|
||||
if type(key) ~= keyType then
|
||||
return true
|
||||
elseif type(key) == "number" and key > maxKey then
|
||||
maxKey = key
|
||||
end
|
||||
end
|
||||
|
||||
-- Array keys must be sequential
|
||||
if keyType == "number" and maxKey > #object then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--[=[
|
||||
Validates a value to ensure it can be synced over a remote event.
|
||||
--[[
|
||||
Emits a warning if the value is a table that cannot be synced via
|
||||
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
||||
with remote argument limitations, such as sparse arrays or mixed tables.
|
||||
|
||||
@param value The value to validate.
|
||||
@param key The key of the value in the table.
|
||||
@error Throws an error if the value cannot be synced.
|
||||
]=]
|
||||
local function validate(value: any, key: any)
|
||||
local typeOfKey = type(key)
|
||||
local typeOfValue = type(value)
|
||||
|
||||
if not SAFE_KEYS[typeOfKey] then
|
||||
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
|
||||
elseif UNSAFE_VALUES[typeOfValue] then
|
||||
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
|
||||
elseif typeOfValue == "table" then
|
||||
if getmetatable(value) ~= nil then
|
||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
||||
elseif isTableUnsafe(value) then
|
||||
error(
|
||||
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
|
||||
.. "This can be for the following reasons:\n"
|
||||
.. "1. The object is an array with non-sequential keys\n"
|
||||
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if typeOfKey == "number" then
|
||||
if key == math.huge or key == -math.huge then
|
||||
error("Cannot sync infinity as key")
|
||||
elseif key ~= math.floor(key) then
|
||||
error("Cannot sync non-integer number as key")
|
||||
end
|
||||
@param value The table value.
|
||||
]]
|
||||
local function validate(value: { [any]: any })
|
||||
if isTableUnsafe(value) then
|
||||
warn(
|
||||
"Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
|
||||
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
|
||||
value
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
137
packages/charm-sync/test/client.test.luau
Normal file
137
packages/charm-sync/test/client.test.luau
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../../deep-charm/src")
|
||||
local client = require("../src/client")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test.afterEach(function()
|
||||
client.removeAllSignals()
|
||||
end)
|
||||
|
||||
test("should hydrate atom", function()
|
||||
local a = Charm.atom(1)
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = 2 } },
|
||||
})
|
||||
|
||||
assert(a() == 2, "should set a to the initial value")
|
||||
end)
|
||||
|
||||
test("should merge patch", function()
|
||||
local a = Charm.atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
||||
assert(a() == previous, "should set a to the initial value")
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = { b = 3 } } },
|
||||
})
|
||||
|
||||
assert(a() ~= previous, "should apply patch")
|
||||
assert(a().b == 3, "should set b to the new value")
|
||||
assert(a().c == 2, "should not change c")
|
||||
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()
|
||||
local a = Charm.atom({ 1 })
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
||||
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
|
||||
-- was JSON-serialized and deserialized. The client should be able to catch
|
||||
-- this and convert the string key back to a number.
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = { ["2"] = 2 } } },
|
||||
})
|
||||
|
||||
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
|
||||
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
|
||||
end)
|
||||
|
||||
test("should merge multiple patches", function()
|
||||
local a = Charm.atom(1)
|
||||
local calls = {}
|
||||
|
||||
client.addSignals({ a = a })
|
||||
|
||||
Charm.subscribe(a, function(value)
|
||||
table.insert(calls, value)
|
||||
end)
|
||||
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = 2 } },
|
||||
{ type = "patch", data = { a = 3 } },
|
||||
})
|
||||
|
||||
assert(a() == 3, "should apply the last patch")
|
||||
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
test("should batch patches", function()
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local states = {}
|
||||
|
||||
client.addSignals({ a = a, b = b })
|
||||
|
||||
Charm.subscribe(a, function()
|
||||
states.b = b()
|
||||
end)
|
||||
|
||||
Charm.subscribe(b, function()
|
||||
states.a = a()
|
||||
end)
|
||||
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a(), b = b() } },
|
||||
})
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = 1, b = 2 } },
|
||||
})
|
||||
|
||||
assert(a() == 1, "should set a to the new value")
|
||||
assert(b() == 2, "should set b to the new value")
|
||||
assert(states.a == 1, "sub to a should run after b updates")
|
||||
assert(states.b == 2, "sub to b should run after a updates")
|
||||
end)
|
||||
|
||||
return {}
|
||||
97
packages/charm-sync/test/e2e.test.luau
Normal file
97
packages/charm-sync/test/e2e.test.luau
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../../deep-charm/src")
|
||||
local client = require("../src/client")
|
||||
local config = require("../src/config")
|
||||
local server = require("../src/server")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test.afterEach(function()
|
||||
server.reset()
|
||||
client.removeAllSignals()
|
||||
config.preserveHistory = false
|
||||
end)
|
||||
|
||||
test("should replicate server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = DeepCharm.reactive({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = DeepCharm.reactive({ count = -1 })
|
||||
|
||||
client.addSignals({ a = clientA, b = clientB })
|
||||
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||
server.connect(function(syncTarget, payloads)
|
||||
if syncTarget == target then
|
||||
client.patch(payloads)
|
||||
end
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(clientA().count == 0, "Client atom A should be initialized to 0")
|
||||
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
|
||||
|
||||
serverA({ count = 1 })
|
||||
serverB.count = 1
|
||||
server.flush()
|
||||
assert(clientA().count == 1, "Client atom A 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)
|
||||
|
||||
serverA({ count = 2 })
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(runs == 0, "Client reactive B should not trigger update")
|
||||
end)
|
||||
|
||||
test("should replicate historical server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = DeepCharm.reactive({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = DeepCharm.reactive({ count = -1 })
|
||||
|
||||
config.preserveHistory = true
|
||||
|
||||
client.addSignals({ a = clientA, b = clientB })
|
||||
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||
server.connect(function(syncTarget, payloads)
|
||||
if syncTarget == target then
|
||||
client.patch(payloads)
|
||||
end
|
||||
end)
|
||||
|
||||
local expectedA = { -1, 0, 1, 2, 3, 4 }
|
||||
local expectedB = { -1, 0, 1, 2 }
|
||||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedA, 1)
|
||||
assert(expected == clientA().count, `Client reactive A should be {expected}`)
|
||||
end)
|
||||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedB, 1)
|
||||
assert(expected == clientB.count, `Client reactive B should be {expected}`)
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
|
||||
serverA({ count = 1 })
|
||||
serverA({ count = 2 })
|
||||
serverB.count = 1
|
||||
serverB.count = 2
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(clientB.count == 2, "Client reactive B should be updated to 2")
|
||||
|
||||
serverA({ count = 3 })
|
||||
serverA({ count = 4 })
|
||||
server.flush()
|
||||
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
||||
end)
|
||||
|
||||
return {}
|
||||
23
packages/charm-sync/test/init.luau
Normal file
23
packages/charm-sync/test/init.luau
Normal 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 {}
|
||||
133
packages/charm-sync/test/patch.test.luau
Normal file
133
packages/charm-sync/test/patch.test.luau
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
local config = require("../src/config")
|
||||
local patch = require("../src/patch")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||
if object[1] then
|
||||
local copy = {}
|
||||
for index = 1, #object do
|
||||
copy[index] = object[index]
|
||||
end
|
||||
return copy
|
||||
elseif table.maxn(object) ~= #object then
|
||||
local copy = {}
|
||||
for key, value in object do
|
||||
copy[tostring(key)] = value
|
||||
end
|
||||
return copy
|
||||
else
|
||||
return object
|
||||
end
|
||||
end
|
||||
|
||||
test.afterEach(function()
|
||||
config.fixArrays = true
|
||||
config.validatePatches = true
|
||||
end)
|
||||
|
||||
test("returns a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local nextState = { foo = "bar", baz = "quux" }
|
||||
local diffs = patch.diff(prevState, nextState)
|
||||
|
||||
assert(diffs ~= nil, "should return a patch")
|
||||
assert(diffs.baz == "quux", "should return the changed value")
|
||||
assert(diffs.foo == nil, "should omit unchanged values")
|
||||
end)
|
||||
|
||||
test("applies a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local diffs = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
assert(applied ~= nil, "should return a new state")
|
||||
assert(applied.baz == "quux", "should apply the changed value")
|
||||
assert(applied.foo == "bar", "should keep the unchanged value")
|
||||
assert(applied.qaz == "qux", "should add the new value")
|
||||
end)
|
||||
|
||||
test("allows nested diffs", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local diffs = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
assert(applied.foo.bar == "qux", "should apply the nested patch")
|
||||
end)
|
||||
|
||||
test("preserves array density when applying", function()
|
||||
local prevState = { "a", "b", "c", "d" }
|
||||
local nextState = { ["2"] = "B", ["4"] = "D" }
|
||||
local applied = patch.apply(prevState, nextState)
|
||||
|
||||
assert(applied[1] == "a", "should keep a")
|
||||
assert(applied[2] == "B", "should apply B")
|
||||
assert(applied[3] == "c", "should keep c")
|
||||
assert(applied[4] == "D", "should apply D")
|
||||
|
||||
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
|
||||
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
|
||||
end)
|
||||
|
||||
test("preserves array density after serdes", function()
|
||||
-- This particular change results in a patch that cannot be serialized
|
||||
-- without losing information. Charm resolves this issue internally.
|
||||
local prevState = { "a", "b", "c", "d", "e" }
|
||||
local nextState = { "A", "b", "c", "D" }
|
||||
|
||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
for index in prevState do
|
||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||
end
|
||||
|
||||
for index in applied do
|
||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||
end
|
||||
end)
|
||||
|
||||
test("cannot preserve sparse arrays", function()
|
||||
config.validatePatches = false
|
||||
|
||||
local prevState = {}
|
||||
local nextState = { [2] = "foo" }
|
||||
|
||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
-- Not enough information is available to determine whether 'diffs'
|
||||
-- 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")
|
||||
end)
|
||||
|
||||
test("optionally fixes sparse arrays", function()
|
||||
config.fixArrays = true
|
||||
|
||||
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
||||
end
|
||||
|
||||
config.fixArrays = false
|
||||
|
||||
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
||||
end
|
||||
end)
|
||||
|
||||
test("patches mutable state", function()
|
||||
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
|
||||
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
|
||||
assert(targetState.baz == "quux", "should apply the changed value")
|
||||
assert(targetState.foo == "bar", "should keep the unchanged value")
|
||||
assert(targetState.qaz == "qux", "should add the new value")
|
||||
end)
|
||||
|
||||
test("removes table from mutable state", function()
|
||||
local targetState = { foo = { bar = "baz" } }
|
||||
patch.applyMutable(targetState, { foo = { __none = "__none" } })
|
||||
assert(targetState.foo == nil, "should remove the table")
|
||||
end)
|
||||
|
||||
return {}
|
||||
456
packages/charm-sync/test/server.test.luau
Normal file
456
packages/charm-sync/test/server.test.luau
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../../deep-charm/src")
|
||||
local config = require("../src/config")
|
||||
local patch = require("../src/patch")
|
||||
local server = require("../src/server")
|
||||
local test = require("../../../tests/test")
|
||||
local types = require("../src/types")
|
||||
|
||||
test.afterEach(function()
|
||||
server.reset()
|
||||
end)
|
||||
|
||||
test("should replicate state changes", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local c = Charm.atom(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 == a(), "Expected init payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
|
||||
|
||||
a(1)
|
||||
b(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 == a(), "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should replicate partial state patches", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({ x = 0, y = 0 })
|
||||
local b = Charm.atom(0 :: number?)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b })
|
||||
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 == a().x, "Expected init payload to contain atom 'a.x'")
|
||||
assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
|
||||
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||
|
||||
a({ x = 1, y = 0 })
|
||||
b(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 == 1, "Expected patch payload to contain atom 'a.x'")
|
||||
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
|
||||
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
|
||||
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()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.connect(function(target, payloads)
|
||||
if target == clientA then
|
||||
payloadsA = payloads
|
||||
elseif target == clientB then
|
||||
payloadsB = payloads
|
||||
end
|
||||
end)
|
||||
|
||||
a(1)
|
||||
server.flush()
|
||||
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")
|
||||
|
||||
a(2)
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
server.flush()
|
||||
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(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||
assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
|
||||
end)
|
||||
|
||||
test("should init new atoms", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
server.addSignalsToClient(client, { b = b })
|
||||
a(1)
|
||||
b(1)
|
||||
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.b == b(), "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[2].type == "patch", "Expected second payload to be patch")
|
||||
assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should only replicate to synced clients", function()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.addSignalsToClient(clientB, { b = b })
|
||||
server.connect(function(target, payloads)
|
||||
if target == clientA then
|
||||
payloadsA = payloads
|
||||
elseif target == clientB then
|
||||
payloadsB = payloads
|
||||
end
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
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.b == nil, "Expected init payload to not contain atom 'b' for client A")
|
||||
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||
assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
|
||||
assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
|
||||
|
||||
payloadsA = {}
|
||||
payloadsB = {}
|
||||
a(1)
|
||||
server.flush()
|
||||
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.b == nil, "Expected patch payload to not contain atom 'b' for client A")
|
||||
assert(#payloadsB == 0, "Expected no payloads for client B")
|
||||
|
||||
payloadsA = {}
|
||||
payloadsB = {}
|
||||
b(1)
|
||||
server.flush()
|
||||
assert(#payloadsA == 0, "Expected no payloads for client A")
|
||||
assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
|
||||
assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
|
||||
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
|
||||
end)
|
||||
|
||||
test("should serialize state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
payloads = p
|
||||
end)
|
||||
server.flush()
|
||||
|
||||
a({ 1, 2, 0, 4 })
|
||||
server.flush()
|
||||
assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
|
||||
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
|
||||
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.describe("historical", function()
|
||||
test.beforeEach(function()
|
||||
config.preserveHistory = true
|
||||
end)
|
||||
|
||||
test.afterEach(function()
|
||||
config.preserveHistory = false
|
||||
end)
|
||||
|
||||
test("should replicate granular state changes", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local c = Charm.atom(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 == 0, "Expected init payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
|
||||
|
||||
a(1)
|
||||
a(2)
|
||||
b(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 == 1, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||
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()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.connect(function(target, payloads)
|
||||
if target == clientA then
|
||||
payloadsA = payloads
|
||||
elseif target == clientB then
|
||||
payloadsB = payloads
|
||||
end
|
||||
end)
|
||||
|
||||
a(1)
|
||||
a(2)
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||
assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
|
||||
assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
|
||||
assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
|
||||
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
a(3)
|
||||
a(4)
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||
assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
|
||||
assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
|
||||
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||
assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
|
||||
assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
|
||||
assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
|
||||
end)
|
||||
|
||||
test("should init new atoms", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, ...)
|
||||
payloads = ...
|
||||
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'")
|
||||
|
||||
server.addSignalsToClient(client, { b = b })
|
||||
a(1)
|
||||
a(2)
|
||||
b(1)
|
||||
b(2)
|
||||
server.flush()
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[3].type == "init", "Expected third payload to be init")
|
||||
assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
|
||||
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||
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)
|
||||
|
||||
return {}
|
||||
22
packages/charm-sync/test/utils.test.luau
Normal file
22
packages/charm-sync/test/utils.test.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
local Charm = require("../Charm")
|
||||
local test = require("../../../tests/test")
|
||||
local utils = require("../src/utils")
|
||||
|
||||
test("should flatten functions", function()
|
||||
local input = {
|
||||
a = Charm.atom(0),
|
||||
b = { c = Charm.atom(0) },
|
||||
d = { e = { f = utils.signalToAtom<<number>>(Charm.signal(0)) } },
|
||||
}
|
||||
|
||||
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
|
||||
|
||||
assert(result.a == input.a, "should flatten the first level")
|
||||
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
||||
assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
|
||||
|
||||
assert(result.b == nil, "should remove the original nested tables")
|
||||
assert(result.d == nil, "should remove the original nested tables")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
[package]
|
||||
name = "littensy/charm-sync"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"test",
|
||||
"src/index.d.ts",
|
||||
"package.json",
|
||||
"Charm.luau",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@^0.11.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "Charm",
|
||||
"name": "charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,24 @@
|
|||
{
|
||||
"name": "@rbxts/charm",
|
||||
"version": "0.10.0",
|
||||
"description": "An atomic state management library for Roblox",
|
||||
"repository": "littensy/charm",
|
||||
"version": "0.11.1",
|
||||
"description": "Atomic state management based on alien-signals",
|
||||
"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.md . && cp ../../README.md .",
|
||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type AtomOptions<T> = types.AtomOptions<T>
|
||||
|
||||
--[=[
|
||||
Creates a new atom with the given state.
|
||||
|
||||
@param state The initial state.
|
||||
@param options Optional configuration.
|
||||
@return A new atom.
|
||||
]=]
|
||||
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
|
||||
local equals = options and options.equals
|
||||
|
||||
local function atom(...)
|
||||
if select("#", ...) == 0 then
|
||||
local index = store.capturing.index
|
||||
|
||||
if index > 0 then
|
||||
store.capturing.stack[index][atom] = true
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local nextState = store.peek(..., state)
|
||||
|
||||
if state ~= nextState and not (equals and equals(state, nextState)) then
|
||||
state = nextState
|
||||
store.notify(atom)
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
store.listeners[atom] = setmetatable({}, { __mode = "v" })
|
||||
|
||||
return atom
|
||||
end
|
||||
|
||||
return atom
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomOptions<T> = types.AtomOptions<T>
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
--[=[
|
||||
Creates a read-only atom that derives its state from one or more atoms.
|
||||
Used to avoid unnecessary recomputations if multiple listeners depend on
|
||||
the same atoms.
|
||||
|
||||
@param callback The function that produces the state.
|
||||
@param options Optional configuration.
|
||||
@return A new read-only atom.
|
||||
]=]
|
||||
local function computed<T>(callback: Selector<T>, options: AtomOptions<T>?): Selector<T>
|
||||
local dependencies, state = store.capture(callback)
|
||||
local computedAtom = atom(state, options)
|
||||
local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
|
||||
|
||||
local function listener()
|
||||
local computedAtom = computedRef.current
|
||||
|
||||
if computedAtom then
|
||||
store.disconnect(dependencies, listener)
|
||||
dependencies, state = store.capture(callback)
|
||||
store.connect(dependencies, listener, computedAtom)
|
||||
computedAtom(state)
|
||||
end
|
||||
end
|
||||
|
||||
store.connect(dependencies, listener, computedAtom)
|
||||
|
||||
return computedAtom
|
||||
end
|
||||
|
||||
return computed
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
|
||||
type Cleanup = () -> ()
|
||||
|
||||
--[=[
|
||||
Runs the given callback immediately and whenever any atom it depends on
|
||||
changes. Returns a cleanup function that unsubscribes the callback.
|
||||
|
||||
@param callback The function to run.
|
||||
@return A function that unsubscribes the callback.
|
||||
]=]
|
||||
local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup
|
||||
local dependencies = {}
|
||||
local cleanup: Cleanup?
|
||||
local disconnected = false
|
||||
local disconnect
|
||||
|
||||
local function listener()
|
||||
if cleanup then
|
||||
cleanup()
|
||||
end
|
||||
|
||||
store.disconnect(dependencies, listener)
|
||||
dependencies, cleanup = store.capture(callback, disconnect)
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, listener)
|
||||
end
|
||||
end
|
||||
|
||||
function disconnect()
|
||||
if disconnected then
|
||||
return
|
||||
end
|
||||
|
||||
disconnected = true
|
||||
store.disconnect(dependencies, listener)
|
||||
|
||||
if cleanup then
|
||||
cleanup()
|
||||
end
|
||||
end
|
||||
|
||||
dependencies, cleanup = store.capture(callback, disconnect)
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, listener)
|
||||
end
|
||||
|
||||
return disconnect
|
||||
end
|
||||
|
||||
return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup
|
||||
459
packages/charm/src/index.d.ts
vendored
459
packages/charm/src/index.d.ts
vendored
|
|
@ -1,187 +1,280 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
type Key = string | number | symbol;
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
declare namespace Charm {
|
||||
/**
|
||||
* A primitive state container that can be read from or written to. When
|
||||
* the state changes, all subscribers are notified.
|
||||
*
|
||||
* @param state The next state or a function that produces the next state.
|
||||
* @returns The current state.
|
||||
*/
|
||||
interface Atom<State> extends Selector<State> {
|
||||
(nextState: State | ((prevState: State) => State)): State;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Selector<State> = () => State;
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @deprecated Use `Selector<T>` instead.
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Molecule<State> = Selector<State>;
|
||||
|
||||
/**
|
||||
* Infers the type of the state produced by the given function.
|
||||
*/
|
||||
type StateOf<T> = T extends Selector<infer State> ? State : never;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
/**
|
||||
* A function that determines whether the state has changed. By default,
|
||||
* a strict equality check (`===`) is used.
|
||||
*/
|
||||
equals?: (prev: State, next: State) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new atom with the given state.
|
||||
*
|
||||
* @param state The initial state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new atom.
|
||||
*/
|
||||
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
|
||||
|
||||
// Overload for no arguments
|
||||
function atom<State>(state?: State, options?: AtomOptions<State>): Atom<State | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a read-only atom that derives its state from one or more atoms.
|
||||
* Used to avoid unnecessary recomputations if multiple listeners depend on
|
||||
* the same atoms.
|
||||
*
|
||||
* @param callback The function that produces the state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new read-only atom.
|
||||
*/
|
||||
function computed<State>(callback: Selector<State>, options?: AtomOptions<State>): Selector<State>;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the given atom or selector. The callback is
|
||||
* called with the current state and the previous state immediately after a
|
||||
* change occurs.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param listener The function to call when the state changes.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function subscribe<State>(callback: Selector<State>, listener: (state: State, prev: State) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Runs the given callback immediately and whenever any atom it depends on
|
||||
* changes. Returns a cleanup function that unsubscribes the callback.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function effect(callback: () => Cleanup | void): Cleanup;
|
||||
|
||||
/**
|
||||
* Returns the result of the function without subscribing to changes. If a
|
||||
* non-function value is provided, it is returned as is.
|
||||
*
|
||||
* @param callback The atom or selector to get the state of.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The current state.
|
||||
*/
|
||||
function peek<State, Args extends unknown[]>(callback: State | ((...args: Args) => State), ...args: Args): State;
|
||||
|
||||
/**
|
||||
* Returns whether the given value is an atom.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is an atom, otherwise `false`.
|
||||
*/
|
||||
function isAtom(value: unknown): value is Atom<any>;
|
||||
|
||||
/**
|
||||
* Runs the given function and schedules listeners to be notified only once
|
||||
* after the function has completed. Useful for batching multiple changes.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
*/
|
||||
function batch(callback: () => void): void;
|
||||
|
||||
/**
|
||||
* Captures all atoms that are read during the function call and returns them
|
||||
* along with the result of the function. Useful for tracking dependencies.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A tuple containing the captured atoms and the result of the function.
|
||||
*/
|
||||
function capture<State>(callback: Selector<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
|
||||
|
||||
/**
|
||||
* Notifies all subscribers of the given atom that the state has changed.
|
||||
* Mainly for running effects, since subscriptions check for equality.
|
||||
*
|
||||
* @param atom The atom to notify.
|
||||
*/
|
||||
function notify<State>(atom: Atom<State>): void;
|
||||
|
||||
/**
|
||||
* Creates an instance of `factory` for each item in the atom's state, and
|
||||
* cleans up the instance when the item is removed. Returns a cleanup function
|
||||
* that unsubscribes all instances.
|
||||
*
|
||||
* @param callback The atom or selector to observe.
|
||||
* @param factory The function that tracks the lifecycle of each item.
|
||||
* @returns A function that unsubscribes all instances.
|
||||
*/
|
||||
function observe<Item>(
|
||||
callback: Selector<readonly Item[]>,
|
||||
factory: (item: Item, index: number) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function observe<Key, Item>(
|
||||
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function observe<Key extends string | number | symbol, Item>(
|
||||
callback: Selector<{ readonly [P in Key]: Item }>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
/**
|
||||
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
||||
* function returns `undefined`, the entry is omitted from the resulting map.
|
||||
* When the atom changes, the `mapper` is called for each entry in the state
|
||||
* to compute the new state.
|
||||
*
|
||||
* @param callback The atom or selector to map.
|
||||
* @param mapper The function that maps each entry.
|
||||
* @returns A new atom with the mapped state.
|
||||
*/
|
||||
function mapped<V0, K1, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function mapped<V0, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => V1,
|
||||
): Selector<readonly V1[]>;
|
||||
|
||||
function mapped<K0, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<{ readonly [P in K0]: V0 }>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
export enum ReactiveFlags {
|
||||
None = 0b000000,
|
||||
Mutable = 0b000001,
|
||||
Watching = 0b000010,
|
||||
RecursedCheck = 0b000100,
|
||||
Recursed = 0b001000,
|
||||
Dirty = 0b010000,
|
||||
Pending = 0b100000,
|
||||
}
|
||||
|
||||
export interface ReactiveNode {
|
||||
deps?: Link;
|
||||
depsTail?: Link;
|
||||
subs?: Link;
|
||||
subsTail?: Link;
|
||||
flags: ReactiveFlags;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
version: number;
|
||||
dep: ReactiveNode;
|
||||
sub: ReactiveNode;
|
||||
prevSub?: Link;
|
||||
nextSub?: Link;
|
||||
prevDep?: Link;
|
||||
nextDep?: Link;
|
||||
}
|
||||
|
||||
export interface Atom<T> {
|
||||
(update: Update<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;
|
||||
|
||||
/**
|
||||
* Global flags that modify the behavior of Charm's reactive system.
|
||||
*/
|
||||
export const flags: {
|
||||
/**
|
||||
* Enforces synchronous, non-yielding behavior in signals and effects.
|
||||
* Also enables state validation in Charm Sync to catch sync errors early.
|
||||
* Enabled by default in Roblox Studio.
|
||||
*/
|
||||
strict: boolean;
|
||||
/**
|
||||
* Enforces immutability of tables by deep-freezing table values. Enabled
|
||||
* by default in Roblox Studio.
|
||||
*/
|
||||
frozen: boolean;
|
||||
/**
|
||||
* Whether parent effects should track inner effects and clean them up
|
||||
* when the parent effect re-runs. This is usually desirable, but can be
|
||||
* disabled to revert to the old behavior. Defaults to `true`.
|
||||
*/
|
||||
trackInnerEffects: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a reactive signal that stores a value and notifies subscribers when
|
||||
* the value changes. The signal provides both a getter and a setter.
|
||||
*
|
||||
* @param initialValue The initial value of the signal.
|
||||
* @param equals A comparator function to determine if the signal's value has changed.
|
||||
* @returns A tuple containing the getter and setter functions for the signal.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||
*/
|
||||
export function signal<T>(initialValue: T, equals?: Equals<T>): LuaTuple<[getter: Getter<T>, setter: Setter<T>]>;
|
||||
export function signal<T>(): LuaTuple<[getter: Getter<T | undefined>, setter: Setter<T | undefined>]>;
|
||||
|
||||
/**
|
||||
* Creates a reactive atom that acts as both a signal getter and setter.
|
||||
* Calling the atom with an argument sets its value, while calling it with no
|
||||
* arguments returns its current value.
|
||||
*
|
||||
* @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
|
||||
* @param equals A comparator function to determine if the atom's value has changed.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
|
||||
*/
|
||||
export function atom<T>(initialValue: T, equals?: Equals<T>): Atom<T>;
|
||||
export function atom<T>(): Atom<T | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a new read-only signal that is computed based on the values of
|
||||
* other signals. The computed signal's value is automatically updated when a
|
||||
* change is detected in any of its dependencies.
|
||||
*
|
||||
* @param getter A function that produces the next value.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||
*/
|
||||
export function computed<T>(getter: (previousValue?: T) => T): Getter<T>;
|
||||
|
||||
/**
|
||||
* Creates an effect that runs a callback in response to signal state changes.
|
||||
* An effect tracks which signals are accessed during its execution, and runs
|
||||
* the callback when those signals change.
|
||||
*
|
||||
* The effect callback may return a cleanup function, which gets called once,
|
||||
* either before the effect re-runs or when it is disposed.
|
||||
*
|
||||
* If the effect is called within another effect, it will be disposed when the
|
||||
* parent effect re-runs.
|
||||
*
|
||||
* @param fn The function to run when dependencies change.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
||||
*/
|
||||
export function effect(fn: () => Cleanup | void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates an effect scope that can capture reactive effects and computed
|
||||
* signals created within it so that they can be disposed together.
|
||||
*
|
||||
* @param fn A function that may create reactive effects.
|
||||
* @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
|
||||
* @returns A function for disposing all effects created within the scope.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
||||
*/
|
||||
export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup;
|
||||
|
||||
/**
|
||||
* Allows you to manually trigger updates for downstream dependencies when
|
||||
* you've directly mutated a signal's value without using the signal setter.
|
||||
*
|
||||
* @param fn The dependency or a function that calls multiple dependencies.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
|
||||
*/
|
||||
export function trigger(fn: () => void): void;
|
||||
|
||||
/**
|
||||
* Binds the cleanup function to the current active effect or effect scope. If
|
||||
* there is no active effect, a warning is logged, unless `failSilently` is
|
||||
* set to `true`.
|
||||
*
|
||||
* @param fn The cleanup function to register.
|
||||
* @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
|
||||
*/
|
||||
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
|
||||
|
||||
/**
|
||||
* Runs the function without subscribing to signal updates and prevents the
|
||||
* current effect or scope from tracking inner effects created within the
|
||||
* function.
|
||||
*
|
||||
* 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 args Arguments to pass to the function.
|
||||
* @returns The return value of the function.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||
*/
|
||||
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
||||
/**
|
||||
* Combines multiple updates into a single "commit" that runs effects and
|
||||
* computed signals after the provided function finishes running.
|
||||
*
|
||||
* @param fn A function that performs multiple updates.
|
||||
* @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#batchcallback
|
||||
*/
|
||||
export function batch<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
||||
/**
|
||||
* Creates an effect that runs the callback one immediately, and then again
|
||||
* when the value returned by the getter changes. The callback receives the new
|
||||
* value and the previous value as arguments.
|
||||
*
|
||||
* @param getter A function that returns the value to watch.
|
||||
* @param callback The function to run when the value changes.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||
*/
|
||||
export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates an effect that only runs the callback when the value returned by the
|
||||
* getter changes. The callback receives the new value and the previous value
|
||||
* as arguments.
|
||||
*
|
||||
* @param getter A function that returns the value to watch.
|
||||
* @param callback The function to run when the value changes.
|
||||
* @returns A function for disposing the effect.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||
*/
|
||||
export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Creates a new read-only signal that is computed by mapping over the entries
|
||||
* of the source table. If the key is omitted from the transform function's
|
||||
* return value, the original key is preserved.
|
||||
*
|
||||
* This function is optimized to minimize unnecessary updates by only calling
|
||||
* the transform function for entries that have changed since the last run.
|
||||
*
|
||||
* @param getter A signal or function that returns a table
|
||||
* @param transform Function that maps each entry from the source to a new entry in the mapped table
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
|
||||
*/
|
||||
export function mapped<VI, KI, VO, KO = KI>(
|
||||
getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
|
||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): Getter<ReadonlyMap<KO, VO>>;
|
||||
// Overload for arrays
|
||||
export function mapped<VI, VO, K extends Key = number>(
|
||||
getter: Getter<readonly VI[]>,
|
||||
transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
|
||||
// Overload for objects
|
||||
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||
getter: Getter<{ readonly [K in KI]: VI }>,
|
||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||
): Getter<{ readonly [K in KO]: VO }>;
|
||||
|
||||
/**
|
||||
* Calls the callback function for each unique key in the table returned by the
|
||||
* getter. The callback is called once for each key, and again when a new key
|
||||
* is added.
|
||||
*
|
||||
* The callback may return a cleanup function, which will be called when the
|
||||
* key is removed or when the entire callback is disposed.
|
||||
*
|
||||
* Effects created within the callback are cleaned up when the item is removed
|
||||
* from the table or when the callback is disposed.
|
||||
*
|
||||
* @param getter A function that returns the table to observe.
|
||||
* @param callback A function that is called for each key in the table.
|
||||
* @returns A function for disposing the observer.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||
*/
|
||||
export function observe<V, K = number>(
|
||||
getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
|
||||
callback: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
// Overload for objects
|
||||
export function observe<V, K extends Key>(
|
||||
getter: Getter<{ readonly [K in Key]: V }>,
|
||||
callback: (value: V, key: K) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
/**
|
||||
* Returns the currently active subscriber (effect or computed node).
|
||||
*
|
||||
* @return Current active subscriber or `nil`
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Starts a batch operation, incrementing the batch depth counter. Effects
|
||||
* will not run until the outermost batch ends.
|
||||
*/
|
||||
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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +0,0 @@
|
|||
local atom = require(script.Parent.atom)
|
||||
local store = require(script.Parent.store)
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
type Map =
|
||||
(<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>)
|
||||
& (<K0, V0, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: V1 }>)
|
||||
& (<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Selector<{ [K1]: V1 }>)
|
||||
|
||||
--[=[
|
||||
Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
||||
function returns `undefined`, the entry is omitted from the resulting map.
|
||||
When the atom changes, the `mapper` is called for each entry in the state
|
||||
to compute the new state.
|
||||
|
||||
@param callback The atom or selector to map.
|
||||
@param mapper The function that maps each entry.
|
||||
@return A new atom with the mapped state.
|
||||
]=]
|
||||
local function mapped<K0, V0, K1, V1>(callback: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Selector<{ [K1]: V1 }>
|
||||
local mappedAtom = atom({})
|
||||
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
|
||||
local prevMappedItems: { [K1]: V1 } = {}
|
||||
local unsubscribe
|
||||
|
||||
local function listener(items: { [K0]: V0 })
|
||||
local mappedAtom = mappedAtomRef.current
|
||||
|
||||
if not mappedAtom then
|
||||
return unsubscribe()
|
||||
end
|
||||
|
||||
local mappedItems = table.clone(mappedAtom())
|
||||
local mappedKeys = {}
|
||||
|
||||
-- TODO: Only call mapper if the item has changed.
|
||||
for key, item in next, items do
|
||||
local newItem, newKey = mapper(item, key)
|
||||
if newKey == nil then
|
||||
newKey = key :: any
|
||||
end
|
||||
if mappedItems[newKey :: K1] ~= newItem then
|
||||
mappedItems[newKey :: K1] = newItem :: V1
|
||||
else
|
||||
mappedKeys[newKey] = key
|
||||
end
|
||||
end
|
||||
|
||||
for key in next, prevMappedItems do
|
||||
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
|
||||
mappedItems[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
prevMappedItems = mappedItems
|
||||
mappedAtom(mappedItems)
|
||||
end
|
||||
|
||||
unsubscribe = subscribe(callback, listener)
|
||||
|
||||
store.peek(function(): ()
|
||||
listener(callback())
|
||||
end)
|
||||
|
||||
return mappedAtom
|
||||
end
|
||||
|
||||
return mapped :: Map
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
local subscribe = require(script.Parent.subscribe)
|
||||
local types = require(script.Parent.types)
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
local function noop() end
|
||||
|
||||
--[=[
|
||||
Creates an instance of `factory` for each item in the atom's state, and
|
||||
cleans up the instance when the item is removed. Returns a cleanup function
|
||||
that unsubscribes all instances.
|
||||
|
||||
@param callback The atom or selector to observe.
|
||||
@param factory The function that tracks the lifecycle of each item.
|
||||
@return A function that unsubscribes all instances.
|
||||
]=]
|
||||
local function observe<K, V>(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> ()
|
||||
local connections: { [K]: () -> () } = {}
|
||||
|
||||
local function listener(state: { [K]: V })
|
||||
for key, disconnect in next, connections do
|
||||
if state[key] == nil then
|
||||
connections[key] = nil
|
||||
disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in next, state do
|
||||
if not connections[key] then
|
||||
connections[key] = factory(value, key) or noop
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local unsubscribe = subscribe(callback, listener)
|
||||
|
||||
store.peek(function(): ()
|
||||
listener(callback())
|
||||
end)
|
||||
|
||||
return function()
|
||||
unsubscribe()
|
||||
for _, disconnect in next, connections do
|
||||
disconnect()
|
||||
end
|
||||
table.clear(connections)
|
||||
end
|
||||
end
|
||||
|
||||
return observe
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
local types = require(script.Parent.types)
|
||||
type Atom<T> = types.Atom<T>
|
||||
type Set<T> = types.Set<T>
|
||||
type WeakMap<K, V> = types.WeakMap<K, V>
|
||||
|
||||
local __DEV__ = _G.__DEV__
|
||||
|
||||
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local batched: Set<() -> ()> = {}
|
||||
local batching = false
|
||||
|
||||
local capturing = {
|
||||
stack = {} :: { Set<Atom<any>> },
|
||||
index = 0,
|
||||
}
|
||||
|
||||
--[=[
|
||||
Calls the given function and returns the result. If the function yields or
|
||||
throws an error, the thread is closed and an error is thrown. Regardless of
|
||||
the outcome, the `finally` function is called to clean up any resources.
|
||||
|
||||
@param callback The function to run.
|
||||
@param finally Cleanup logic to run before error handling.
|
||||
@param ... Arguments to pass to the callback.
|
||||
@return The result of the callback.
|
||||
]=]
|
||||
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
|
||||
if __DEV__ then
|
||||
local thread = coroutine.create(callback)
|
||||
local success, result = coroutine.resume(thread, ...)
|
||||
|
||||
if finally then
|
||||
finally()
|
||||
end
|
||||
|
||||
if coroutine.status(thread) == "suspended" then
|
||||
local source, line, name = debug.info(callback, "sln")
|
||||
|
||||
coroutine.close(thread)
|
||||
|
||||
error(
|
||||
"Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead."
|
||||
.. `\nFunction defined at: {source}:{line}`
|
||||
.. if name == "" then "" else ` function {name}`
|
||||
)
|
||||
elseif not success then
|
||||
local source, line, name = debug.info(callback, "sln")
|
||||
|
||||
error(
|
||||
"An error occurred while running an atom function"
|
||||
.. `\nFunction defined at: {source}:{line}`
|
||||
.. (if name == "" then "" else ` function {name}`)
|
||||
.. `\nError: {result}`
|
||||
)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
if not finally then
|
||||
return callback(...)
|
||||
end
|
||||
|
||||
local success, result = pcall(callback, ...)
|
||||
|
||||
finally()
|
||||
assert(success, result)
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--[=[
|
||||
Returns whether the given value is an atom.
|
||||
|
||||
@param value The value to check.
|
||||
@return `true` if the value is an atom, otherwise `false`.
|
||||
]=]
|
||||
local function isAtom(value: any): boolean
|
||||
return not not (value and listeners[value])
|
||||
end
|
||||
|
||||
--[=[
|
||||
Notifies all subscribers of the given atom that the state has changed.
|
||||
|
||||
@param atom The atom to notify.
|
||||
]=]
|
||||
local function notify(atom: Atom<any>)
|
||||
if batching then
|
||||
for listener in next, listeners[atom] do
|
||||
batched[listener] = true
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for listener in next, table.clone(listeners[atom]) do
|
||||
try(listener)
|
||||
end
|
||||
end
|
||||
|
||||
--[=[
|
||||
Returns the result of the function without subscribing to changes. If a
|
||||
non-function value is provided, it is returned as is.
|
||||
|
||||
@param callback The atom or selector to get the state of.
|
||||
@param args Arguments to pass to the function.
|
||||
@return The current state.
|
||||
]=]
|
||||
local function peek<T, U...>(callback: ((U...) -> T) | T, ...: U...): T
|
||||
if type(callback) ~= "function" then
|
||||
return callback
|
||||
end
|
||||
|
||||
if capturing.index == 0 then
|
||||
return callback(...)
|
||||
end
|
||||
|
||||
capturing.index += 1
|
||||
capturing.stack[capturing.index] = {}
|
||||
|
||||
local result = try(callback, function()
|
||||
capturing.stack[capturing.index] = nil
|
||||
capturing.index -= 1
|
||||
end, ...)
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--[=[
|
||||
Captures all atoms that are read during the function call and returns them
|
||||
along with the result of the function. Useful for tracking dependencies.
|
||||
|
||||
@param callback The function to run.
|
||||
@return A tuple containing the captured atoms and the result of the function.
|
||||
]=]
|
||||
local function capture<T, U...>(callback: (U...) -> T, ...: U...): (Set<Atom<any>>, T)
|
||||
-- If the callback is an atom, return it immediately
|
||||
if listeners[callback :: any] then
|
||||
return { [callback :: any] = true }, peek(callback)
|
||||
end
|
||||
|
||||
local dependencies: Set<Atom<any>> = {}
|
||||
|
||||
capturing.index += 1
|
||||
capturing.stack[capturing.index] = dependencies
|
||||
|
||||
local result = try(callback, function()
|
||||
capturing.stack[capturing.index] = nil
|
||||
capturing.index -= 1
|
||||
end, ...)
|
||||
|
||||
return dependencies, result
|
||||
end
|
||||
|
||||
--[=[
|
||||
Runs the given function and schedules listeners to be notified only once
|
||||
after the function has completed. Useful for batching multiple changes.
|
||||
|
||||
@param callback The function to run.
|
||||
]=]
|
||||
local function batch(callback: () -> ())
|
||||
if batching then
|
||||
return callback()
|
||||
end
|
||||
|
||||
batching = true
|
||||
|
||||
try(callback, function()
|
||||
batching = false
|
||||
end)
|
||||
|
||||
for listener in next, batched do
|
||||
try(listener)
|
||||
end
|
||||
|
||||
table.clear(batched)
|
||||
end
|
||||
|
||||
--[=[
|
||||
Subscribes the listener to the changes of the given atoms.
|
||||
|
||||
@param atoms The atoms to listen to.
|
||||
@param listener The function to call when the atoms change.
|
||||
@param ref Optionally bind the lifetime of the listener to a value.
|
||||
]=]
|
||||
local function connect(atoms: Set<Atom<any>>, listener: () -> (), ref: unknown?)
|
||||
for atom in next, atoms do
|
||||
listeners[atom][listener] = ref or true
|
||||
end
|
||||
end
|
||||
|
||||
--[=[
|
||||
Unsubscribes the listener from every atom it was connected to.
|
||||
|
||||
@param atoms The atoms to stop listening to.
|
||||
@param listener The function to stop calling when the atoms change.
|
||||
]=]
|
||||
local function disconnect(atoms: Set<Atom<any>>, listener: () -> ())
|
||||
for atom in next, atoms do
|
||||
listeners[atom][listener] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
listeners = listeners,
|
||||
capturing = capturing,
|
||||
isAtom = isAtom,
|
||||
notify = notify,
|
||||
capture = capture,
|
||||
batch = batch,
|
||||
peek = peek,
|
||||
connect = connect,
|
||||
disconnect = disconnect,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
local store = require(script.Parent.store)
|
||||
local types = require(script.Parent.types)
|
||||
type Selector<T> = types.Selector<T>
|
||||
|
||||
--[=[
|
||||
Subscribes to changes in the given atom or selector. The callback is
|
||||
called with the current state and the previous state immediately after a
|
||||
change occurs.
|
||||
|
||||
@param callback The atom or selector to subscribe to.
|
||||
@param listener The function to call when the state changes.
|
||||
@return A function that unsubscribes the callback.
|
||||
]=]
|
||||
local function subscribe<T>(callback: Selector<T>, listener: (state: T, prev: T) -> ()): () -> ()
|
||||
local dependencies, state = store.capture(callback)
|
||||
local disconnected = false
|
||||
|
||||
local function handler()
|
||||
local prevState = state
|
||||
|
||||
store.disconnect(dependencies, handler)
|
||||
dependencies, state = store.capture(callback)
|
||||
|
||||
if not disconnected then
|
||||
store.connect(dependencies, handler)
|
||||
end
|
||||
|
||||
if state ~= prevState then
|
||||
listener(state, prevState)
|
||||
end
|
||||
end
|
||||
|
||||
store.connect(dependencies, handler)
|
||||
|
||||
return function()
|
||||
if not disconnected then
|
||||
disconnected = true
|
||||
store.disconnect(dependencies, handler)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return subscribe
|
||||
237
packages/charm/src/system.luau
Normal file
237
packages/charm/src/system.luau
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
--[[
|
||||
Reactive system adapted from alien-signals at
|
||||
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
|
||||
]]
|
||||
|
||||
export type ReactiveNode = {
|
||||
deps: Link?,
|
||||
depsTail: Link?,
|
||||
subs: Link?,
|
||||
subsTail: Link?,
|
||||
flags: ReactiveFlags,
|
||||
}
|
||||
|
||||
export type Link = {
|
||||
version: number,
|
||||
dep: ReactiveNode,
|
||||
sub: ReactiveNode,
|
||||
prevSub: Link?,
|
||||
nextSub: Link?,
|
||||
prevDep: Link?,
|
||||
nextDep: Link?,
|
||||
}
|
||||
|
||||
type Stack<T> = {
|
||||
value: T,
|
||||
prev: Stack<T>?,
|
||||
}
|
||||
|
||||
export type ReactiveFlags = number
|
||||
|
||||
local NONE = 0b000000
|
||||
local MUTABLE = 0b000001
|
||||
local WATCHING = 0b000010
|
||||
local RECURSED_CHECK = 0b000100
|
||||
local RECURSED = 0b001000
|
||||
local DIRTY = 0b010000
|
||||
local PENDING = 0b100000
|
||||
|
||||
local ReactiveFlags = table.freeze({
|
||||
None = NONE,
|
||||
Mutable = MUTABLE,
|
||||
Watching = WATCHING,
|
||||
RecursedCheck = RECURSED_CHECK,
|
||||
Recursed = RECURSED,
|
||||
Dirty = DIRTY,
|
||||
Pending = PENDING,
|
||||
})
|
||||
|
||||
local update: (sub: ReactiveNode) -> boolean
|
||||
local notify: (sub: ReactiveNode) -> ()
|
||||
local unwatched: (sub: ReactiveNode) -> ()
|
||||
|
||||
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||
local link = sub.depsTail
|
||||
while link do
|
||||
if link == checkLink then
|
||||
return true
|
||||
end
|
||||
link = link.prevDep
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
|
||||
local prevDep = sub.depsTail
|
||||
if prevDep and prevDep.dep == dep then
|
||||
return
|
||||
end
|
||||
local nextDep = if prevDep then prevDep.nextDep else sub.deps
|
||||
if nextDep and nextDep.dep == dep then
|
||||
nextDep.version = version
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
end
|
||||
local prevSub = dep.subsTail
|
||||
if prevSub and prevSub.version == version and prevSub.sub == sub then
|
||||
return
|
||||
end
|
||||
local newLink: Link = {
|
||||
version = version,
|
||||
dep = dep,
|
||||
sub = sub,
|
||||
prevDep = prevDep,
|
||||
nextDep = nextDep,
|
||||
prevSub = prevSub,
|
||||
}
|
||||
sub.depsTail = newLink
|
||||
dep.subsTail = newLink
|
||||
if nextDep then
|
||||
nextDep.prevDep = newLink
|
||||
end
|
||||
if prevDep then
|
||||
prevDep.nextDep = newLink
|
||||
else
|
||||
sub.deps = newLink
|
||||
end
|
||||
if prevSub then
|
||||
prevSub.nextSub = newLink
|
||||
else
|
||||
dep.subs = newLink
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
return nextDep
|
||||
end
|
||||
|
||||
local function propagate(link: Link)
|
||||
repeat
|
||||
local sub = link.sub
|
||||
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
|
||||
flags = NONE
|
||||
end
|
||||
|
||||
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
||||
notify(sub)
|
||||
end
|
||||
|
||||
if bit32.btest(flags, MUTABLE) then
|
||||
local subSubs = sub.subs
|
||||
if subSubs then
|
||||
propagate(subSubs)
|
||||
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.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == 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
|
||||
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
|
||||
return true
|
||||
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
|
||||
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
|
||||
|
||||
link = link.nextDep :: Link
|
||||
until not link
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function createReactiveSystem(
|
||||
updateFn: (sub: ReactiveNode) -> boolean,
|
||||
notifyFn: (sub: ReactiveNode) -> (),
|
||||
unwatchedFn: (sub: ReactiveNode) -> ()
|
||||
)
|
||||
update = updateFn
|
||||
notify = notifyFn
|
||||
unwatched = unwatchedFn
|
||||
end
|
||||
|
||||
return {
|
||||
ReactiveFlags = ReactiveFlags,
|
||||
link = link,
|
||||
unlink = unlink,
|
||||
propagate = propagate,
|
||||
checkDirty = checkDirty,
|
||||
shallowPropagate = shallowPropagate,
|
||||
createReactiveSystem = createReactiveSystem,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
export type Set<T> = { [T]: true }
|
||||
|
||||
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
|
||||
|
||||
--[=[
|
||||
A primitive state container that can be read from and written to. When the
|
||||
state changes, all subscribers are notified.
|
||||
|
||||
@param state The next state or a function that produces the next state.
|
||||
@return The current state, if no arguments are provided.
|
||||
]=]
|
||||
export type Atom<T> = (state: (T | (T) -> T)?) -> T
|
||||
|
||||
--[=[
|
||||
A function that depends on one or more atoms and produces a state. Can be
|
||||
used to derive state from atoms.
|
||||
|
||||
@template State The type of the state.
|
||||
@return The current state.
|
||||
]=]
|
||||
export type Selector<T> = () -> T
|
||||
|
||||
--[=[
|
||||
Optional configuration for creating an atom.
|
||||
]=]
|
||||
export type AtomOptions<T> = {
|
||||
--[=[
|
||||
A function that determines whether the state has changed. By default,
|
||||
a strict equality check (`===`) is used.
|
||||
]=]
|
||||
equals: (prev: T, next: T) -> boolean,
|
||||
}
|
||||
|
||||
--[=[
|
||||
A payload that can be sent from the server to the client to synchronize
|
||||
state between the two.
|
||||
]=]
|
||||
export type SyncPayload = {
|
||||
type: "patch" | "init",
|
||||
data: { [any]: any },
|
||||
}
|
||||
|
||||
return nil
|
||||
220
packages/charm/test/computed.test.luau
Normal file
220
packages/charm/test/computed.test.luau
Normal 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 {}
|
||||
593
packages/charm/test/effect.test.luau
Normal file
593
packages/charm/test/effect.test.luau
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local endBatch = Charm.endBatch
|
||||
local onCleanup = Charm.onCleanup
|
||||
local setActiveSub = Charm.setActiveSub
|
||||
local signal = Charm.signal
|
||||
local startBatch = Charm.startBatch
|
||||
|
||||
test("should clear subscriptions when untracked by all subscribers", function()
|
||||
local bRunTimes = 0
|
||||
|
||||
local a, setA = signal(1)
|
||||
local b = computed(function()
|
||||
bRunTimes += 1
|
||||
return a() * 2
|
||||
end)
|
||||
local stopEffect = effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(bRunTimes == 1, "should run initially")
|
||||
setA(2)
|
||||
assert(bRunTimes == 2, "should run after change")
|
||||
stopEffect()
|
||||
setA(3)
|
||||
assert(bRunTimes == 2, "should not run after stop")
|
||||
end)
|
||||
|
||||
test("should not run untracked inner effect", function()
|
||||
local a, setA = signal(3)
|
||||
local b = computed(function()
|
||||
return a() > 0
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
if b() then
|
||||
effect(function()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
setA(1)
|
||||
setA(0)
|
||||
end)
|
||||
|
||||
test("should run outer effect first", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(1)
|
||||
|
||||
effect(function()
|
||||
if a() > 0 then
|
||||
effect(function()
|
||||
b()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
startBatch()
|
||||
setB(0)
|
||||
setA(0)
|
||||
endBatch()
|
||||
end)
|
||||
|
||||
test("should not trigger inner effect when resolve maybe dirty", function()
|
||||
local a, setA = signal(0)
|
||||
local b = computed(function()
|
||||
return a() % 2
|
||||
end)
|
||||
|
||||
local innerTriggerTimes = 0
|
||||
|
||||
effect(function()
|
||||
effect(function()
|
||||
b()
|
||||
innerTriggerTimes += 1
|
||||
if innerTriggerTimes >= 2 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
end)
|
||||
|
||||
test("should notify inner effects in the same order as non-inner effects", function()
|
||||
local getA, setA = signal(0)
|
||||
local getB, setB = signal(0)
|
||||
local c = computed(function()
|
||||
return getA() - getB()
|
||||
end)
|
||||
local order1 = {}
|
||||
local order2 = {}
|
||||
local order3 = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order1, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order1, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
c()
|
||||
effect(function()
|
||||
table.insert(order2, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order2, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
end)
|
||||
|
||||
effectScope(function()
|
||||
effect(function()
|
||||
table.insert(order3, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order3, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(order1)
|
||||
table.clear(order2)
|
||||
table.clear(order3)
|
||||
|
||||
startBatch()
|
||||
setB(1)
|
||||
setA(1)
|
||||
endBatch()
|
||||
|
||||
assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
|
||||
assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
|
||||
assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
|
||||
end)
|
||||
|
||||
test("should custom effect support batch", function()
|
||||
local function batchEffect(fn: () -> ())
|
||||
return effect(function()
|
||||
startBatch()
|
||||
local success, result: unknown = pcall(fn)
|
||||
endBatch()
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local logs: { string } = {}
|
||||
local a = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
local aa = computed(function()
|
||||
table.insert(logs, "aa-0")
|
||||
if a() == 0 then
|
||||
setB(1)
|
||||
end
|
||||
table.insert(logs, "aa-1")
|
||||
return nil
|
||||
end)
|
||||
|
||||
local bb = computed(function()
|
||||
table.insert(logs, "bb")
|
||||
return b()
|
||||
end)
|
||||
|
||||
batchEffect(function()
|
||||
bb()
|
||||
end)
|
||||
batchEffect(function()
|
||||
aa()
|
||||
end)
|
||||
|
||||
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
|
||||
end)
|
||||
|
||||
test("should duplicate subscribers do not affect the notify order", function()
|
||||
local src1, setSrc1 = signal(0)
|
||||
local src2, setSrc2 = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "a")
|
||||
local currentSub = setActiveSub(nil)
|
||||
local isOne = src2() == 1
|
||||
setActiveSub(currentSub)
|
||||
if isOne then
|
||||
src1()
|
||||
end
|
||||
src2()
|
||||
src1()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order, "b")
|
||||
src1()
|
||||
end)
|
||||
setSrc2(1) -- src1.subs: a -> b -> a
|
||||
|
||||
table.clear(order)
|
||||
setSrc1(src1() + 1)
|
||||
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should handle side effect with inner effects", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
effect(function()
|
||||
a()
|
||||
table.insert(order, "a")
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
table.insert(order, "b")
|
||||
end)
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setB(1)
|
||||
setA(1)
|
||||
end)
|
||||
|
||||
-- DEVIATION: This assertion was moved outside of the effect so that it
|
||||
-- runs after the batch updates have been processed
|
||||
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
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)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
effect(function()
|
||||
d()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
setA(true)
|
||||
assert(triggers == 2, "did not update indirectly")
|
||||
end)
|
||||
|
||||
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c, setC = signal(0)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return 0
|
||||
end)
|
||||
|
||||
local effect3Executed = false
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
end)
|
||||
effect(function()
|
||||
if a() == 2 then
|
||||
error("Error in effect 2")
|
||||
end
|
||||
end)
|
||||
effect(function()
|
||||
a()
|
||||
d()
|
||||
effect3Executed = true
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
|
||||
effect3Executed = false
|
||||
local success, err = pcall(setA, 2)
|
||||
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
|
||||
|
||||
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
|
||||
setB(1)
|
||||
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
|
||||
setC(1)
|
||||
assert(effect3Executed, "effect 3 should have executed after updating signal c")
|
||||
end)
|
||||
|
||||
test("should allow finite recursion", function()
|
||||
local src, setSrc = signal(0)
|
||||
local order = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, src())
|
||||
setSrc(math.min(src() + 1, 5))
|
||||
end)
|
||||
assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should run cleanups in sequence", function()
|
||||
local order = {}
|
||||
|
||||
local a, setA = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
a()
|
||||
onCleanup(function()
|
||||
table.insert(order, "a")
|
||||
end)
|
||||
onCleanup(function()
|
||||
table.insert(order, "b")
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(table.concat(order) == "", "should not cleanup immediately")
|
||||
|
||||
setA(true)
|
||||
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
stopEffect()
|
||||
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setA(false)
|
||||
assert(table.concat(order) == "", "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run inner effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
src()
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
setSrc(true)
|
||||
assert(cleanups == 2, "should cleanup after outer update")
|
||||
stopEffect()
|
||||
assert(cleanups == 4, "should cleanup after outer dispose")
|
||||
setSrc(false)
|
||||
assert(cleanups == 4, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run cleanups outside any evaluation context", function()
|
||||
local calls = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c = computed(function()
|
||||
if a() == 0 then
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
b()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
return a()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
calls += 1
|
||||
c()
|
||||
end)
|
||||
assert(calls == 1, "should run once")
|
||||
|
||||
calls = 0
|
||||
setA(1)
|
||||
assert(calls == 1, "should run once after a update")
|
||||
|
||||
calls = 0
|
||||
setB(1)
|
||||
assert(calls == 0, "should not run after b update")
|
||||
end)
|
||||
|
||||
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = {}
|
||||
|
||||
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 {}
|
||||
95
packages/charm/test/effectScope.test.luau
Normal file
95
packages/charm/test/effectScope.test.luau
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local signal = Charm.signal
|
||||
local onCleanup = Charm.onCleanup
|
||||
|
||||
test("should not trigger after stop", function()
|
||||
local count, setCount = signal(1)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
triggers += 1
|
||||
count()
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
|
||||
setCount(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
end)
|
||||
|
||||
setCount(3)
|
||||
assert(triggers == 3, "did not run after update outside scope")
|
||||
stopScope()
|
||||
setCount(4)
|
||||
assert(triggers == 3, "ran after stop")
|
||||
end)
|
||||
|
||||
test("should run indirect scope cleanup", function()
|
||||
local cleanups = 0
|
||||
local cleanup: () -> ()
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
cleanup = effectScope(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
stopScope()
|
||||
assert(cleanups == 1, "should cleanup from parent dispose")
|
||||
cleanup()
|
||||
assert(cleanups == 1, "should skip redundant dispose")
|
||||
end)
|
||||
|
||||
test("should run indirect effect cleanup", function()
|
||||
local cleanups = 0
|
||||
local a, setA = signal(false)
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
a()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
setA(true)
|
||||
assert(cleanups == 1, "should cleanup once")
|
||||
stopScope()
|
||||
assert(cleanups == 2, "should cleanup after outer dispose")
|
||||
setA(false)
|
||||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should dispose inner effects if created in an effect", function()
|
||||
local source, setSource = signal(1)
|
||||
local triggers = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
effect(function()
|
||||
dispose = effectScope(function()
|
||||
effect(function()
|
||||
source()
|
||||
triggers += 1
|
||||
end)
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
end)
|
||||
|
||||
setSource(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
dispose()
|
||||
setSource(3)
|
||||
assert(triggers == 2, "ran after dispose")
|
||||
end)
|
||||
|
||||
return {}
|
||||
200
packages/charm/test/errors.test.luau
Normal file
200
packages/charm/test/errors.test.luau
Normal 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 {}
|
||||
65
packages/charm/test/gc.test.luau
Normal file
65
packages/charm/test/gc.test.luau
Normal 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 {}
|
||||
55
packages/charm/test/init.luau
Normal file
55
packages/charm/test/init.luau
Normal 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 {}
|
||||
77
packages/charm/test/listen.test.luau
Normal file
77
packages/charm/test/listen.test.luau
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local listen = Charm.listen
|
||||
|
||||
test("should listen to selector", function()
|
||||
local source, setSource = signal(0)
|
||||
local current, previous
|
||||
|
||||
local unsub = listen(function()
|
||||
return source() + 1
|
||||
end, function(a, b)
|
||||
current = a
|
||||
previous = b
|
||||
end)
|
||||
|
||||
assert(current == 1 and previous == nil, "should run initially")
|
||||
setSource(0)
|
||||
assert(current == 1 and previous == nil, "should skip redundant update")
|
||||
setSource(1)
|
||||
assert(current == 2 and previous == 1, "should run with new value")
|
||||
unsub()
|
||||
setSource(2)
|
||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||
end)
|
||||
|
||||
test("should run in untracked", function()
|
||||
local runs = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
listen(a, function()
|
||||
runs += 1
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(runs == 1, "should run once on init")
|
||||
setA(1)
|
||||
assert(runs == 2, "should run once on change")
|
||||
setB(1)
|
||||
assert(runs == 2, "should not run on b change")
|
||||
setA(2)
|
||||
assert(runs == 3, "should run on a change")
|
||||
end)
|
||||
|
||||
test("should run if first value is nil", function()
|
||||
local runs = 0
|
||||
local source, setSource = signal(nil :: number?)
|
||||
|
||||
listen(source, function()
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
assert(runs == 1, "should run once on init")
|
||||
setSource(1)
|
||||
assert(runs == 2, "should run once on change")
|
||||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
listen(function(): "even" | "odd"
|
||||
return if a() % 2 == 0 then "even" else "odd"
|
||||
end, function(value)
|
||||
table.insert(order, value)
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
setA(3)
|
||||
setA(4)
|
||||
setA(6)
|
||||
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
128
packages/charm/test/mapped.test.luau
Normal file
128
packages/charm/test/mapped.test.luau
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local mapped = Charm.mapped
|
||||
|
||||
test("should map state", function()
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
setSrc({ 4, 5, 6 })
|
||||
assert(table.concat(map()) == "567", table.concat(map()))
|
||||
end)
|
||||
|
||||
test("should not map unchanged values", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
assert(calls == 3, "should map 3 values")
|
||||
|
||||
setSrc({ 1, 3, 3 })
|
||||
assert(table.concat(map()) == "244", table.concat(map()))
|
||||
assert(calls == 4, "should map 1 change")
|
||||
|
||||
setSrc({})
|
||||
assert(#map() == 0, "should be empty")
|
||||
assert(calls == 4, "should not map for removed values")
|
||||
end)
|
||||
|
||||
test("should not update if no values change", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
local prev = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
|
||||
setSrc({ 4, 5, 6, 7 })
|
||||
setSrc({ 1, 2, 3 })
|
||||
map()
|
||||
assert(calls == 3, "should not map unchanged values")
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
assert(prev == map(), "should not create new table")
|
||||
end)
|
||||
|
||||
test("should filter values", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return if value < 0 then nil else value
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(state.a and state.b and state.c, "should map all values")
|
||||
|
||||
calls = 0
|
||||
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||
state = map()
|
||||
assert(calls == 2, "should map 2 values")
|
||||
assert(state.a == nil, "should remove a")
|
||||
assert(state.b and state.c, "should keep b and c")
|
||||
assert(state.d == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should map keys", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||
local map = mapped(src, function(value: number, key: string)
|
||||
calls += 1
|
||||
return if value < 0 then nil else value, string.upper(key)
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(not (state.a and state.b and state.c), "should discard old keys")
|
||||
assert(state.A == 1, "should map a")
|
||||
assert(state.B == 2, "should map b")
|
||||
assert(state.C == 3, "should map c")
|
||||
|
||||
calls = 0
|
||||
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||
state = map()
|
||||
assert(calls == 2, "should map 2 values")
|
||||
assert(state.A == nil and state.a == nil, "should remove a")
|
||||
assert(state.B and state.C, "should keep b and c")
|
||||
assert(state.D == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should remove old key if mapper returns a new key", function()
|
||||
local src, setSrc = signal({ 1 })
|
||||
local map = mapped(src, function(value: number, index: number)
|
||||
return true, `{index}={value}`
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(state["1=1"], "should have mapped first element to key 1=1")
|
||||
|
||||
setSrc({ 2 })
|
||||
state = map()
|
||||
assert(state["1=1"] == nil, "should have removed old key 1=1")
|
||||
assert(state["1=2"], "should have remapped element to key 1=2")
|
||||
|
||||
setSrc({ 3, 1 })
|
||||
state = map()
|
||||
assert(state["1=2"] == nil, "should have removed old key 1=2")
|
||||
assert(state["1=3"], "should have remapped element to key 1=3")
|
||||
assert(state["2=1"], "should have mapped second element to key 2=1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
198
packages/charm/test/observe.test.luau
Normal file
198
packages/charm/test/observe.test.luau
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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 observe = Charm.observe
|
||||
|
||||
test("should observe items", function()
|
||||
local items, setItems = signal({ 0, 0, 0 })
|
||||
local updates = {}
|
||||
local cleanups = {}
|
||||
|
||||
observe(items, 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)
|
||||
setItems({ 1, 2, 3 })
|
||||
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
setItems({ 1, 2, nil :: any, 4 })
|
||||
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
|
||||
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
|
||||
end)
|
||||
|
||||
test("should dispose scopes", function()
|
||||
local items, setItems = signal({ 1, 2, 3 })
|
||||
local a, setA = signal(0)
|
||||
local runs = {}
|
||||
local disposes = {}
|
||||
|
||||
local dispose = observe(items, function(item)
|
||||
effect(function()
|
||||
table.insert(runs, item)
|
||||
a()
|
||||
end)
|
||||
effectScope(function()
|
||||
return function()
|
||||
table.insert(disposes, item)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(runs)
|
||||
setItems({ 1, 2 })
|
||||
setA(1)
|
||||
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
|
||||
assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
|
||||
|
||||
table.clear(runs)
|
||||
table.clear(disposes)
|
||||
dispose()
|
||||
setA(2)
|
||||
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
|
||||
assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
|
||||
end)
|
||||
|
||||
test("should queue addition and removal", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item == 1 then
|
||||
setItems({ 1, 2, 3, 4 })
|
||||
elseif item == 4 then
|
||||
setItems({ 1 })
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should handle recursive additions and removals", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits: { string } = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item < 5 then
|
||||
setItems(function(state: { number })
|
||||
state = table.clone(state)
|
||||
table.insert(state, item + 1)
|
||||
return state
|
||||
end)
|
||||
else
|
||||
setItems({})
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should handle self removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits: { string } = {}
|
||||
|
||||
observe(items, function(item, index)
|
||||
setItems(function(state: { number })
|
||||
state = table.clone(state)
|
||||
state[index] = nil
|
||||
return state
|
||||
end)
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should allow disposal during addition", function()
|
||||
local items, setItems = signal({})
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
if item == 1 then
|
||||
dispose()
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
setItems({ 1, 2, 3, 4 })
|
||||
assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should allow disposal during removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
dispose()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
setItems({ 1, 2, 3, nil :: any, 5 })
|
||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive update", function()
|
||||
local items, setItems = signal({})
|
||||
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
|
||||
local success, err = pcall(function()
|
||||
setItems({ {} })
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive init", function()
|
||||
local items, setItems = signal({ {} })
|
||||
|
||||
local success, err = pcall(function()
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
67
packages/charm/test/signal.test.luau
Normal file
67
packages/charm/test/signal.test.luau
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local effect = Charm.effect
|
||||
|
||||
test("should accept equality function", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0, function(current: number, incoming: number)
|
||||
return current == incoming or incoming % 2 == 1
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, src())
|
||||
end)
|
||||
|
||||
setSrc(2)
|
||||
setSrc(3)
|
||||
assert(src() == 2, "should skip odd value")
|
||||
|
||||
setSrc(4)
|
||||
setSrc(5)
|
||||
assert(src() == 4, "should skip odd value")
|
||||
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
test("should enforce immutability", function()
|
||||
local src, setSrc = signal({ nested = {} })
|
||||
assert(not pcall(function()
|
||||
src().nested.a = 1
|
||||
end), "should not allow mutation of initial value")
|
||||
|
||||
setSrc({ nested = { a = 1 } })
|
||||
assert(not pcall(function()
|
||||
src().nested.a = 2
|
||||
end), "should not allow mutation of new value")
|
||||
end)
|
||||
|
||||
test("should not freeze values with metatables", function()
|
||||
local src = signal({ object = setmetatable({}, {}) })
|
||||
assert(
|
||||
pcall(function()
|
||||
src().object.a = 2
|
||||
end),
|
||||
"should allow mutation of values with metatables"
|
||||
)
|
||||
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 {}
|
||||
91
packages/charm/test/subscribe.test.luau
Normal file
91
packages/charm/test/subscribe.test.luau
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
|
||||
test("should subscribe to selector", function()
|
||||
local source, setSource = signal(0)
|
||||
local current, previous
|
||||
|
||||
local unsub = subscribe(function()
|
||||
return source() + 1
|
||||
end, function(a, b)
|
||||
current = a
|
||||
previous = b
|
||||
end)
|
||||
|
||||
assert(not current and not previous, "should not run initially")
|
||||
setSource(0)
|
||||
assert(not current and not previous, "should skip redundant update")
|
||||
setSource(1)
|
||||
assert(current == 2 and previous == 1, "should run with new value")
|
||||
unsub()
|
||||
setSource(2)
|
||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||
end)
|
||||
|
||||
test("should run in untracked", function()
|
||||
local runs = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
subscribe(a, function()
|
||||
runs += 1
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(runs == 0, "should not run once on init")
|
||||
setA(1)
|
||||
assert(runs == 1, "should run once on change")
|
||||
setB(1)
|
||||
assert(runs == 1, "should not run on b change")
|
||||
setA(2)
|
||||
assert(runs == 2, "should run on a change")
|
||||
end)
|
||||
|
||||
test("should ensure correct execution order", function()
|
||||
local order: { number } = {}
|
||||
local n1, setN1 = signal(0)
|
||||
local n2, setN2 = signal(0)
|
||||
local sum = computed(function()
|
||||
return n1() + n2()
|
||||
end)
|
||||
|
||||
subscribe(n1, function()
|
||||
table.insert(order, 1)
|
||||
setN2(n2() + 1)
|
||||
end)
|
||||
|
||||
subscribe(sum, function()
|
||||
table.insert(order, 2)
|
||||
end)
|
||||
|
||||
subscribe(n1, function()
|
||||
table.insert(order, 3)
|
||||
end)
|
||||
|
||||
setN1(n1() + 1)
|
||||
|
||||
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order: { "even" | "odd" } = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
subscribe(function(): "even" | "odd"
|
||||
return if a() % 2 == 0 then "even" else "odd"
|
||||
end, function(value)
|
||||
table.insert(order, value)
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
setA(3)
|
||||
setA(4)
|
||||
setA(6)
|
||||
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
484
packages/charm/test/topology.test.luau
Normal file
484
packages/charm/test/topology.test.luau
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
|
||||
--[[
|
||||
Tests adopted with thanks from preact-core implementation at
|
||||
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022-present Preact Team
|
||||
|
||||
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
|
||||
]]
|
||||
|
||||
test.describe("graph updates", function()
|
||||
test("should drop A->B->A updates", function()
|
||||
-- A
|
||||
-- / |
|
||||
-- B | <- Looks like a flag doesn't it? :D
|
||||
-- \ |
|
||||
-- C
|
||||
-- |
|
||||
-- D
|
||||
local a = atom(2)
|
||||
|
||||
local b = computed(function()
|
||||
return a() - 1
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a() + b()
|
||||
end)
|
||||
|
||||
local computeCalls = 0
|
||||
local compute = function()
|
||||
computeCalls += 1
|
||||
return "d: " .. c()
|
||||
end
|
||||
local d = computed(compute)
|
||||
|
||||
-- Trigger read
|
||||
assert(d() == "d: 3", "d should be 3")
|
||||
assert(computeCalls == 1, "compute should be called once")
|
||||
computeCalls = 0
|
||||
|
||||
a(4)
|
||||
d()
|
||||
assert(computeCalls == 1, "compute should drop update")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (diamond graph)", function()
|
||||
-- In this scenario "D" should only update once when "A" receives
|
||||
-- an update. This is sometimes referred to as the "diamond" scenario.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b() .. " " .. c()
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "a a", "d should be 'a a'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(d() == "aa aa", "d should be 'aa aa'")
|
||||
assert(spyCalls == 2, "compute should be called only twice")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (diamond graph + tail)", function()
|
||||
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- \ /
|
||||
-- D
|
||||
-- |
|
||||
-- E
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local d = computed(function()
|
||||
return b() .. " " .. c()
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return d()
|
||||
end
|
||||
local e = computed(spy)
|
||||
|
||||
assert(e() == "a a", "e should be 'a a'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(e() == "aa aa", "e should be 'aa aa'")
|
||||
assert(spyCalls == 2, "compute should be called only twice")
|
||||
end)
|
||||
|
||||
test("should bail out if result is the same", function()
|
||||
-- Bail out if value of "B" never changes
|
||||
-- A->B->C
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
a()
|
||||
return "foo"
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b()
|
||||
end
|
||||
local c = computed(spy)
|
||||
|
||||
assert(c() == "foo", "c should be 'foo'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(c() == "foo", "c should be 'foo'")
|
||||
assert(spyCalls == 1, "compute should bail out")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (jagged diamond graph + tails)", function()
|
||||
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- | |
|
||||
-- | D
|
||||
-- \ /
|
||||
-- E
|
||||
-- / \
|
||||
-- F G
|
||||
local a = atom("a")
|
||||
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local d = computed(function()
|
||||
return c()
|
||||
end)
|
||||
|
||||
local eSpyCalls = 0
|
||||
local eSpyTime = 0
|
||||
local eSpy = function()
|
||||
eSpyCalls += 1
|
||||
eSpyTime = os.clock()
|
||||
return b() .. " " .. d()
|
||||
end
|
||||
local e = computed(eSpy)
|
||||
|
||||
local fSpyCalls = 0
|
||||
local fSpyTime = 0
|
||||
local fSpy = function()
|
||||
fSpyCalls += 1
|
||||
fSpyTime = os.clock()
|
||||
return e()
|
||||
end
|
||||
local f = computed(fSpy)
|
||||
local gSpyCalls = 0
|
||||
local gSpyTime = 0
|
||||
local gSpy = function()
|
||||
gSpyCalls += 1
|
||||
gSpyTime = os.clock()
|
||||
return e()
|
||||
end
|
||||
local g = computed(gSpy)
|
||||
|
||||
assert(f() == "a a", "f should be 'a a'")
|
||||
assert(fSpyCalls == 1, "f should be called once")
|
||||
|
||||
assert(g() == "a a", "g should be 'a a'")
|
||||
assert(gSpyCalls == 1, "g should be called once")
|
||||
|
||||
eSpyCalls, eSpyTime = 0, 0
|
||||
fSpyCalls, fSpyTime = 0, 0
|
||||
gSpyCalls, gSpyTime = 0, 0
|
||||
|
||||
a("b")
|
||||
|
||||
assert(e() == "b b", "e should be 'b b'")
|
||||
assert(eSpyCalls == 1, "e should run once after update")
|
||||
|
||||
assert(f() == "b b", "f should be 'b b'")
|
||||
assert(fSpyCalls == 1, "f should run once after update")
|
||||
|
||||
assert(g() == "b b", "g should be 'b b'")
|
||||
assert(gSpyCalls == 1, "g should run once after update")
|
||||
|
||||
eSpyCalls, eSpyTime = 0, 0
|
||||
fSpyCalls, fSpyTime = 0, 0
|
||||
gSpyCalls, gSpyTime = 0, 0
|
||||
|
||||
a("c")
|
||||
|
||||
assert(e() == "c c", "e should be 'c c'")
|
||||
assert(eSpyCalls == 1, "e should run once after update")
|
||||
|
||||
assert(f() == "c c", "f should be 'c c'")
|
||||
assert(fSpyCalls == 1, "f should run once after update")
|
||||
|
||||
assert(g() == "c c", "g should be 'c c'")
|
||||
assert(gSpyCalls == 1, "g should run once after update")
|
||||
|
||||
-- top to bottom
|
||||
assert(eSpyTime < fSpyTime, "e should be called before f")
|
||||
-- left to right
|
||||
assert(eSpyTime < gSpyTime, "e should be called before g")
|
||||
end)
|
||||
|
||||
test("should only subscribe to core listened to", function()
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B C <- we don't listen to C
|
||||
local a = atom("a")
|
||||
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return a()
|
||||
end
|
||||
computed(spy)
|
||||
|
||||
assert(b() == "a", "b should be 'a'")
|
||||
assert(spyCalls == 0, "compute should not run")
|
||||
|
||||
a("aa")
|
||||
assert(b() == "aa", "b should be 'aa'")
|
||||
assert(spyCalls == 0, "compute should not run")
|
||||
end)
|
||||
|
||||
test("should only subscribe to core listened to II", function()
|
||||
-- Here both "B" and "C" are active in the beginning, but
|
||||
-- "B" becomes inactive later. At that point it should
|
||||
-- not receive any updates anymore.
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B D <- we don't listen to C
|
||||
-- |
|
||||
-- *C
|
||||
local a = atom("a")
|
||||
local spyBCalls = 0
|
||||
local spyB = function()
|
||||
spyBCalls += 1
|
||||
return a()
|
||||
end
|
||||
local b = computed(spyB)
|
||||
|
||||
local spyCCalls = 0
|
||||
local spyC = function()
|
||||
spyCCalls += 1
|
||||
return b()
|
||||
end
|
||||
local c = computed(spyC)
|
||||
|
||||
local d = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local result = ""
|
||||
local unsub = effect(function()
|
||||
result = c()
|
||||
end)
|
||||
|
||||
assert(result == "a", "result should be 'a'")
|
||||
assert(d() == "a", "d should be 'a'")
|
||||
|
||||
spyBCalls = 0
|
||||
spyCCalls = 0
|
||||
unsub()
|
||||
|
||||
a("aa")
|
||||
|
||||
assert(spyBCalls == 0, "b should not run")
|
||||
assert(spyCCalls == 0, "c should not run")
|
||||
assert(d() == "aa", "d should be 'aa'")
|
||||
end)
|
||||
|
||||
test("should ensure subs update even if one dep unmarks it", function()
|
||||
-- In this scenario "C" always returns the same value. When "A"
|
||||
-- changes, "B" will update, then "C" at which point its update
|
||||
-- to "D" will be unmarked. But "D" must still update because
|
||||
-- "B" marked it. If "D" isn't updated, then we have a bug.
|
||||
-- A
|
||||
-- / \
|
||||
-- B *C <- returns same value every time
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local spyResult: string?
|
||||
local spy = function()
|
||||
spyResult = b() .. " " .. c()
|
||||
return spyResult
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "a c", "d should be 'a c'")
|
||||
spyResult = nil
|
||||
|
||||
a("aa")
|
||||
d()
|
||||
assert(spyResult == "aa c", "d should have returned 'aa c'")
|
||||
end)
|
||||
|
||||
test("should ensure subs update even if two deps unmark it", function()
|
||||
-- In this scenario both "C" and "D" always return the same
|
||||
-- value. But "E" must still update because "A" marked it.
|
||||
-- If "E" isn't updated, then we have a bug.
|
||||
-- A
|
||||
-- / | \
|
||||
-- B *C *D
|
||||
-- \ | /
|
||||
-- E
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local d = computed(function()
|
||||
a()
|
||||
return "d"
|
||||
end)
|
||||
local spyResult: string?
|
||||
local spy = function()
|
||||
spyResult = b() .. " " .. c() .. " " .. d()
|
||||
return spyResult
|
||||
end
|
||||
local e = computed(spy)
|
||||
|
||||
assert(e() == "a c d", "e should be 'a c d'")
|
||||
spyResult = nil
|
||||
|
||||
a("aa")
|
||||
e()
|
||||
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
|
||||
end)
|
||||
|
||||
test("should support lazy branches", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return if a() > 0 then a() else b()
|
||||
end)
|
||||
|
||||
assert(c() == 0, "c should be 0")
|
||||
a(1)
|
||||
assert(c() == 1, "c should be 1")
|
||||
|
||||
a(0)
|
||||
assert(c() == 0, "c should be 0 again")
|
||||
end)
|
||||
|
||||
test("should not update a sub if all deps unmark it", function()
|
||||
-- In this scenario "B" and "C" always return the same value. When "A"
|
||||
-- changes, "D" should not update.
|
||||
-- A
|
||||
-- / \
|
||||
-- *B *C
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
a()
|
||||
return "b"
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b() .. " " .. c()
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "b c", "d should be 'b c'")
|
||||
spyCalls = 0
|
||||
|
||||
a("aa")
|
||||
assert(spyCalls == 0, "d should not run")
|
||||
end)
|
||||
end)
|
||||
|
||||
test.describe("error handling", function()
|
||||
test("should keep graph consistent on errors during activation", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): nil
|
||||
error("fail")
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local success, result = pcall(b)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
|
||||
a(1)
|
||||
assert(c() == 1, "c should recover and return 1")
|
||||
end)
|
||||
|
||||
test("should keep graph consistent on errors in computeds", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): number
|
||||
if a() == 1 then
|
||||
error("fail")
|
||||
end
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return b()
|
||||
end)
|
||||
|
||||
assert(c() == 0, "c should be 0")
|
||||
|
||||
a(1)
|
||||
local success, result = pcall(b)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
|
||||
a(2)
|
||||
assert(c() == 2, "c should recover and return 2")
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
111
packages/charm/test/trigger.test.luau
Normal file
111
packages/charm/test/trigger.test.luau
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local listen = Charm.listen
|
||||
local signal = Charm.signal
|
||||
local subscribe = Charm.subscribe
|
||||
local trigger = Charm.trigger
|
||||
|
||||
test.beforeEach(function()
|
||||
Charm.flags.frozen = false
|
||||
end)
|
||||
|
||||
test.afterEach(function()
|
||||
Charm.flags.frozen = true
|
||||
end)
|
||||
|
||||
test("should not throw when triggering with no dependencies", function()
|
||||
trigger(function() end)
|
||||
end)
|
||||
|
||||
test("should trigger updates for dependent computed signals", function()
|
||||
local arr = signal({} :: { number })
|
||||
local length = computed(function()
|
||||
return #arr()
|
||||
end)
|
||||
|
||||
assert(length() == 0, "initial length should be 0")
|
||||
table.insert(arr(), 1)
|
||||
trigger(arr)
|
||||
assert(length() == 1, "length should be 1")
|
||||
end)
|
||||
|
||||
test("should trigger updates for the second source signal", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = signal({} :: { number })
|
||||
local length = computed(function()
|
||||
return #src2()
|
||||
end)
|
||||
|
||||
assert(length() == 0, "initial length should be 0")
|
||||
table.insert(src2(), 1)
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(length() == 1, "length should be 1")
|
||||
end)
|
||||
|
||||
test("should trigger effect once", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
effect(function()
|
||||
triggers += 1
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(triggers == 1, "effect should run once initially")
|
||||
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(triggers == 2, "effect should run twice")
|
||||
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()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
subscribe(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 1, "subscribe callback should run after trigger")
|
||||
end)
|
||||
|
||||
test("should trigger updates for listen", function()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
listen(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "listen callback should run once initially")
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 2, "listen callback should run after trigger")
|
||||
end)
|
||||
|
||||
return {}
|
||||
176
packages/charm/test/untracked.test.luau
Normal file
176
packages/charm/test/untracked.test.luau
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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 untracked = Charm.untracked
|
||||
|
||||
test("should run detached from effect", function()
|
||||
local a, setA = signal(1)
|
||||
local effects = 0
|
||||
local innerEffects = {}
|
||||
|
||||
local stopEffect = effect(function()
|
||||
a()
|
||||
untracked(function()
|
||||
local stopInnerEffect = effect(function()
|
||||
effects += 1
|
||||
return function()
|
||||
effects -= 1
|
||||
end
|
||||
end)
|
||||
table.insert(innerEffects, stopInnerEffect)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(effects == 1, "should have one effect")
|
||||
setA(2)
|
||||
assert(effects == 2, "should have two effects")
|
||||
stopEffect()
|
||||
assert(effects == 2, "should still have two effects")
|
||||
for _, stopInnerEffect in innerEffects do
|
||||
stopInnerEffect()
|
||||
end
|
||||
assert(effects == 0, "should have zero effects")
|
||||
end)
|
||||
|
||||
test("should run detached from effect scope", function()
|
||||
local effects = 0
|
||||
local stopEffect: () -> ()
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
untracked(function()
|
||||
stopEffect = effect(function()
|
||||
effects += 1
|
||||
return function()
|
||||
effects -= 1
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(effects == 1, "should have one effect")
|
||||
stopScope()
|
||||
assert(effects == 1, "should still have one effect")
|
||||
stopEffect()
|
||||
assert(effects == 0, "should have zero effects")
|
||||
end)
|
||||
|
||||
test("should block tracking inside effects", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(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 untracked", function()
|
||||
local s, setS = signal(1)
|
||||
local spyCalls = 0
|
||||
|
||||
untracked(function()
|
||||
effect(function()
|
||||
untracked(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()
|
||||
untracked(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 untracked(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 untracked", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local c, setC = signal(3)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
untracked(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 untracked", function()
|
||||
local a, setA = signal(1)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(function()
|
||||
untracked(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 {}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "littensy/charm"
|
||||
version = "0.10.0"
|
||||
version = "0.11.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
5
packages/deep-charm/Charm.luau
Normal file
5
packages/deep-charm/Charm.luau
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
if not script then
|
||||
return require("../charm/src")
|
||||
end
|
||||
|
||||
return require(script.Parent.Parent.charm.src)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "ReactCharm",
|
||||
"name": "deep-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
27
packages/deep-charm/package.json
Normal file
27
packages/deep-charm/package.json
Normal 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
53
packages/deep-charm/src/index.d.ts
vendored
Normal 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;
|
||||
165
packages/deep-charm/src/init.luau
Normal file
165
packages/deep-charm/src/init.luau
Normal 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,
|
||||
}
|
||||
79
packages/deep-charm/test/gc.test.luau
Normal file
79
packages/deep-charm/test/gc.test.luau
Normal 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 {}
|
||||
11
packages/deep-charm/test/init.luau
Normal file
11
packages/deep-charm/test/init.luau
Normal 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 {}
|
||||
232
packages/deep-charm/test/reactive.test.luau
Normal file
232
packages/deep-charm/test/reactive.test.luau
Normal 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 {}
|
||||
9
packages/deep-charm/wally.toml
Normal file
9
packages/deep-charm/wally.toml
Normal 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"
|
||||
1
packages/react-charm/Charm.luau
Normal file
1
packages/react-charm/Charm.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.charm.src)
|
||||
1
packages/react-charm/React.luau
Normal file
1
packages/react-charm/React.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)
|
||||
1
packages/react-charm/ReactRoblox.luau
Normal file
1
packages/react-charm/ReactRoblox.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox)
|
||||
6
packages/react-charm/default.project.json
Normal file
6
packages/react-charm/default.project.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "react-charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
30
packages/react-charm/package.json
Normal file
30
packages/react-charm/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.4.1",
|
||||
"description": "React bindings for Charm state management",
|
||||
"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:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/react": ">=17.0.0"
|
||||
}
|
||||
}
|
||||
31
packages/react-charm/src/index.d.ts
vendored
Normal file
31
packages/react-charm/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Binding } from "@rbxts/react";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useSignalState<T>(getter: () => T, dependencies?: any[]): T;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* @return A binding that stores the current state.
|
||||
*/
|
||||
export function useSignalBinding<T>(getter: () => T, dependencies?: any[]): Binding<T>;
|
||||
|
||||
// Aliases
|
||||
export { useSignalState as useAtom };
|
||||
52
packages/react-charm/src/init.luau
Normal file
52
packages/react-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
local Charm = require("./Charm")
|
||||
local React = require("./React")
|
||||
|
||||
--[[
|
||||
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: {}?): T
|
||||
local value: T, setValue = React.useState(getter)
|
||||
|
||||
React.useLayoutEffect(function(): () -> ()
|
||||
return Charm.listen(getter, setValue :: (T, T?) -> ())
|
||||
end, dependencies :: { any } or {})
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
--[[
|
||||
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
|
||||
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.
|
||||
@return A binding that stores the current state.
|
||||
]]
|
||||
local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
|
||||
local initialValue = React.useMemo(getter, {})
|
||||
local binding, setBinding = React.useBinding(initialValue)
|
||||
|
||||
React.useLayoutEffect(function(): () -> ()
|
||||
return Charm.listen(getter, setBinding :: (T, T?) -> ())
|
||||
end, dependencies :: { any } or {})
|
||||
|
||||
return binding
|
||||
end
|
||||
|
||||
return table.freeze({
|
||||
useSignalState = useSignalState,
|
||||
useSignalBinding = useSignalBinding,
|
||||
useAtom = useSignalState,
|
||||
})
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||
|
||||
local React = require(ReplicatedStorage.DevPackages.React)
|
||||
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
|
||||
local React = require("../../React")
|
||||
local ReactRoblox = require("../../ReactRoblox")
|
||||
|
||||
type RenderedHook<Props, Result> = {
|
||||
result: Result,
|
||||
|
|
@ -15,14 +11,15 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
|
|||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||
local hook = {}
|
||||
|
||||
local function Component(props)
|
||||
local function Component(props: Props)
|
||||
hook.result = callback(props)
|
||||
return nil
|
||||
end
|
||||
|
||||
function hook.rerender(newProps)
|
||||
function hook.rerender(newProps: Props?)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
root:render(React.createElement(Component, props))
|
||||
root:render(React.createElement(Component, props) :: any)
|
||||
end)
|
||||
end
|
||||
|
||||
13
packages/react-charm/test/init.luau
Normal file
13
packages/react-charm/test/init.luau
Normal 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 {}
|
||||
44
packages/react-charm/test/useSignalBinding.test.luau
Normal file
44
packages/react-charm/test/useSignalBinding.test.luau
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
local Charm = require("../Charm")
|
||||
local ReactCharm = require("../src")
|
||||
local renderHook = require("./helpers/renderHook")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("tracks signal state", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalBinding(getState)
|
||||
end)
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 1, "should return 1")
|
||||
end)
|
||||
|
||||
test("accepts a selector", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalBinding(function()
|
||||
return 2 * getState()
|
||||
end)
|
||||
end)
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 2, "should return 2")
|
||||
end)
|
||||
|
||||
test("tracks dependencies", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalBinding(getState, { getState })
|
||||
end)
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 1, "should return 1")
|
||||
|
||||
getState, setState = Charm.signal(0)
|
||||
hook.rerender()
|
||||
assert(hook.result:getValue() == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result:getValue() == 1, "should return 1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
44
packages/react-charm/test/useSignalState.test.luau
Normal file
44
packages/react-charm/test/useSignalState.test.luau
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
local Charm = require("../Charm")
|
||||
local ReactCharm = require("../src")
|
||||
local renderHook = require("./helpers/renderHook")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("tracks signal state", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalState(getState)
|
||||
end)
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 1, "should return 1")
|
||||
end)
|
||||
|
||||
test("accepts a selector", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalState(function()
|
||||
return 2 * getState()
|
||||
end)
|
||||
end)
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 2, "should return 2")
|
||||
end)
|
||||
|
||||
test("tracks dependencies", function()
|
||||
local getState, setState = Charm.signal(0)
|
||||
local hook = renderHook(function()
|
||||
return ReactCharm.useSignalState(getState, { getState })
|
||||
end)
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 1, "should return 1")
|
||||
|
||||
getState, setState = Charm.signal(0)
|
||||
hook.rerender()
|
||||
assert(hook.result == 0, "should return 0")
|
||||
setState(1)
|
||||
assert(hook.result == 1, "should return 1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
[package]
|
||||
name = "littensy/react-charm"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"test",
|
||||
"src/index.d.ts",
|
||||
"package.json",
|
||||
"React.luau",
|
||||
"Charm.luau",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@^0.11.0"
|
||||
React = "jsdotlua/react@^17"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.3.0",
|
||||
"description": "React bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*",
|
||||
"@rbxts/react": "*"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue