mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
Compare commits
70 commits
main
...
charm-v0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e583574751 | ||
|
|
ed9566bfff | ||
|
|
29d8add265 | ||
|
|
898fbdba29 | ||
|
|
fdc598f6e6 | ||
|
|
d890f4fc2c | ||
|
|
96ab2317f2 | ||
|
|
dffeb35933 | ||
|
|
7bb6edb403 | ||
|
|
2aa7bd3e15 | ||
|
|
86ade3c36d | ||
|
|
3d4ce41d20 | ||
|
|
90399a816f | ||
|
|
682b3dfcff | ||
|
|
0f2f2d642c | ||
|
|
ea40833d3b | ||
|
|
37f79ecc3f | ||
|
|
7a88c784fa | ||
|
|
32788b19e1 | ||
|
|
1e0b10aef6 | ||
|
|
bf92413a97 | ||
|
|
7a63993293 | ||
|
|
027c5bf8f8 | ||
|
|
87e7260c16 | ||
|
|
1657e2cb74 | ||
|
|
f5709c3112 | ||
|
|
5a05f01223 | ||
|
|
0dbc0587a0 | ||
|
|
079abaacc1 | ||
|
|
4a07297a13 | ||
|
|
cb52ece4d3 | ||
|
|
a8051257e5 | ||
|
|
77c98ca94a | ||
|
|
708bc55de7 | ||
|
|
d2979ebd4c | ||
|
|
95476e93ec | ||
|
|
a9c60c701f | ||
|
|
581488cb4f | ||
|
|
61d9f424aa | ||
|
|
4edc952c05 | ||
|
|
4931515a95 | ||
|
|
25728e589f | ||
|
|
72f936591e | ||
|
|
210e948225 | ||
|
|
546df8cabe | ||
|
|
4e5f656158 | ||
|
|
a53683d59e | ||
|
|
571d2c0d91 | ||
|
|
dcb1de2844 | ||
|
|
3f1556a6e2 | ||
|
|
554010f1bd | ||
|
|
b61d0b3b86 | ||
|
|
f704acce71 | ||
|
|
ada1b80b87 | ||
|
|
d56672384a | ||
|
|
bcf02a615f | ||
|
|
6bfd2f3d21 | ||
|
|
939a812b43 | ||
|
|
1d5d3aed8c | ||
|
|
e276863b2d | ||
|
|
7c48bd2fa4 | ||
|
|
0b37f9cd54 | ||
|
|
d33c4ce891 | ||
|
|
8d385356cd | ||
|
|
3eba56e298 | ||
|
|
8df67d6fff | ||
|
|
209f9b583b | ||
|
|
a0fc88ede1 | ||
|
|
e34b0c9aca | ||
|
|
861ee5acd7 |
102 changed files with 2932 additions and 4884 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -14,19 +14,11 @@ jobs:
|
||||||
run_install: true
|
run_install: true
|
||||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
|
|
||||||
- name: Setup Lute
|
|
||||||
run: lute setup --with-luaurc
|
|
||||||
|
|
||||||
- name: Check code quality
|
- name: Check code quality
|
||||||
run: lute run check
|
run: sh bin/check.sh
|
||||||
|
|
||||||
- name: Run ESLint and Prettier
|
|
||||||
run: |
|
|
||||||
pnpm eslint packages
|
|
||||||
pnpm prettier --check packages
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: lute run test
|
run: lune run test/spec.server.luau
|
||||||
|
|
||||||
- name: Build models
|
- name: Build models
|
||||||
run: lute run build
|
run: sh bin/build.sh
|
||||||
|
|
|
||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
|
|
@ -11,11 +11,8 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
|
|
||||||
- name: Setup Lute
|
|
||||||
run: lute setup --with-luaurc
|
|
||||||
|
|
||||||
- name: Build models
|
- name: Build models
|
||||||
run: lute run build
|
run: sh bin/build.sh
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
uses: AButler/upload-release-assets@v3.0
|
uses: AButler/upload-release-assets@v3.0
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,7 +2,7 @@
|
||||||
/.pesde
|
/.pesde
|
||||||
/build
|
/build
|
||||||
/packages/**/wally.lock
|
/packages/**/wally.lock
|
||||||
/packages/**/LICENSE*
|
/packages/**/LICENSE.md
|
||||||
/packages/**/README.md
|
/packages/**/README.md
|
||||||
/sourcemap.json
|
/sourcemap.json
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
4
.gitmodules
vendored
4
.gitmodules
vendored
|
|
@ -1,3 +1,3 @@
|
||||||
[submodule "tests/test"]
|
[submodule "test/suite"]
|
||||||
path = tests/test
|
path = test/suite
|
||||||
url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
|
url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
|
||||||
|
|
|
||||||
5
.luaurc
5
.luaurc
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"languageMode": "strict",
|
"languageMode": "strict",
|
||||||
"aliases": {
|
"lint": {
|
||||||
"lute": "~/.lute/typedefs/1.0.0/lute",
|
"LocalShadow": false
|
||||||
"std": "~/.lute/typedefs/1.0.0/std"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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" })
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
(require)("../tests/run.server")
|
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
public-hoist-pattern[]=*@rbxts*
|
||||||
21
LICENSE
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
||||||
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.
|
|
||||||
7
LICENSE.md
Normal file
7
LICENSE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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.
|
||||||
|
|
@ -20,8 +20,8 @@ local function run(name: string, test: () -> ())
|
||||||
|
|
||||||
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||||
|
|
||||||
for name, duration in times do
|
for name, elapsed in times do
|
||||||
table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`)
|
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||||
end
|
end
|
||||||
|
|
||||||
print(table.concat(results, " | "))
|
print(table.concat(results, " | "))
|
||||||
|
|
|
||||||
|
|
@ -3,80 +3,75 @@ local bench = require("./bench")
|
||||||
|
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local mapped = Charm.mapped
|
local mapped = Charm.mapped
|
||||||
|
local setActiveSub = Charm.setActiveSub
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
|
|
||||||
type Key = number
|
type Entry = { number }
|
||||||
type Value = number
|
|
||||||
|
|
||||||
local count = 512
|
local count = 512
|
||||||
local initialState: { [Key]: Value } = table.create(count, 0)
|
local initialState: { Entry } = table.create(count)
|
||||||
|
|
||||||
local function expensiveOperation()
|
for i = 1, count do
|
||||||
for _ = 1, 100 do
|
initialState[i] = table.create(64, i)
|
||||||
continue
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function simpleMapped(
|
local function mapper(value: Entry, key: number): (Entry, number)
|
||||||
source: () -> { [Key]: Value },
|
return table.clone(value), key + 1
|
||||||
mapper: (Value, Key) -> (Value, Key?)
|
end
|
||||||
): () -> { [Key]: Value }
|
|
||||||
|
local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
|
||||||
return computed(function()
|
return computed(function()
|
||||||
local state = source()
|
local state = source()
|
||||||
local nextState = {}
|
local nextState = {}
|
||||||
|
|
||||||
for keyIn, valueIn in state do
|
for index, value in state do
|
||||||
local valueOut, keyOut = mapper(valueIn, keyIn)
|
local currentSub = setActiveSub(nil)
|
||||||
nextState[if keyOut == nil then keyIn else keyOut] = valueOut
|
local success, valueOut, keyOut = pcall(mapper, value, index)
|
||||||
|
setActiveSub(currentSub)
|
||||||
|
|
||||||
|
if success then
|
||||||
|
nextState[keyOut] = valueOut
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return nextState
|
return state
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
|
local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
|
||||||
return function(state)
|
return function(state)
|
||||||
local nextState = table.clone(state)
|
local nextState = table.clone(state)
|
||||||
for key in state do
|
for index, value in state do
|
||||||
if math.random() < updateChance then
|
if math.random() < percent then
|
||||||
nextState[key] += 1
|
nextState[index] = table.clone(value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return nextState
|
return nextState
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, updateChance in { 0.1, 0.5, 1 } do
|
for _, percent in { 0.1, 0.5, 1 } do
|
||||||
local function mapper(value: Value, key: Key): (Value, Key?)
|
bench.run(`mapped {percent}`, function()
|
||||||
expensiveOperation()
|
|
||||||
return value + 1, key + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
bench.run(`mapped {updateChance}`, function()
|
|
||||||
local src, setSrc = signal(initialState)
|
local src, setSrc = signal(initialState)
|
||||||
local out = mapped(src, mapper)
|
local out = mapped(src, mapper)
|
||||||
|
|
||||||
bench.profilebegin("init")
|
bench.profilebegin("init")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
|
setSrc(updatePercentage(percent))
|
||||||
setSrc(updateRandomly(updateChance))
|
|
||||||
|
|
||||||
bench.profilebegin("update")
|
bench.profilebegin("update")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
bench.run(`control {updateChance}`, function()
|
bench.run(`control {percent}`, function()
|
||||||
local src, setSrc = signal(initialState)
|
local src, setSrc = signal(initialState)
|
||||||
local out = simpleMapped(src, mapper)
|
local out = simpleMapped(src, mapper)
|
||||||
|
|
||||||
bench.profilebegin("init")
|
bench.profilebegin("init")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
|
setSrc(updatePercentage(percent))
|
||||||
setSrc(updateRandomly(updateChance))
|
|
||||||
|
|
||||||
bench.profilebegin("update")
|
bench.profilebegin("update")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
local Charm = require("../packages/charm/src")
|
|
||||||
local bench = require("./bench")
|
|
||||||
|
|
||||||
type Atom<T> = Charm.Atom<T>
|
|
||||||
|
|
||||||
local signal = Charm.atom
|
|
||||||
local computed = Charm.computed
|
|
||||||
local effect = Charm.effect
|
|
||||||
|
|
||||||
local function busy(): number
|
|
||||||
local a = 0
|
|
||||||
for i = 1, 100 do
|
|
||||||
a += 1
|
|
||||||
end
|
|
||||||
return a
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local head = signal(0)
|
|
||||||
local computed1 = computed(function()
|
|
||||||
return head()
|
|
||||||
end)
|
|
||||||
local computed2 = computed(function()
|
|
||||||
computed1()
|
|
||||||
return 0
|
|
||||||
end)
|
|
||||||
local computed3 = computed(function()
|
|
||||||
busy() -- heavy computation
|
|
||||||
return computed2() + 1
|
|
||||||
end)
|
|
||||||
local computed4 = computed(function()
|
|
||||||
return computed3() + 2
|
|
||||||
end)
|
|
||||||
local computed5 = computed(function()
|
|
||||||
return computed4() + 3
|
|
||||||
end)
|
|
||||||
effect(function()
|
|
||||||
computed5()
|
|
||||||
busy() -- heavy side effect
|
|
||||||
end)
|
|
||||||
|
|
||||||
bench.run("avoidablePropagation", function()
|
|
||||||
head(1)
|
|
||||||
assert(computed5() == 6)
|
|
||||||
for i = 0, 999 do
|
|
||||||
head(i)
|
|
||||||
end
|
|
||||||
assert(computed5() == 6)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local head = signal(0)
|
|
||||||
local double = computed(function()
|
|
||||||
return head() * 2
|
|
||||||
end)
|
|
||||||
local inverse = computed(function()
|
|
||||||
return -head()
|
|
||||||
end)
|
|
||||||
local current = computed(function()
|
|
||||||
local result = 0
|
|
||||||
for i = 0, 19 do
|
|
||||||
result += if head() % 2 == 1 then double() else inverse()
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
end)
|
|
||||||
|
|
||||||
local callCounter = 0
|
|
||||||
effect(function()
|
|
||||||
current()
|
|
||||||
callCounter += 1
|
|
||||||
end)
|
|
||||||
|
|
||||||
bench.run("unstable", function()
|
|
||||||
head(1)
|
|
||||||
assert(current() == 40)
|
|
||||||
callCounter = 0
|
|
||||||
for i = 0, 99 do
|
|
||||||
head(i)
|
|
||||||
end
|
|
||||||
assert(callCounter == 100)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local head = signal(0)
|
|
||||||
local double = computed(function()
|
|
||||||
return head() * 2
|
|
||||||
end)
|
|
||||||
local inverse = computed(function()
|
|
||||||
return -head()
|
|
||||||
end)
|
|
||||||
local current = computed(function()
|
|
||||||
local result = 0
|
|
||||||
for i = 0, 19 do
|
|
||||||
result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse()
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
end)
|
|
||||||
|
|
||||||
local callCounter = 0
|
|
||||||
effect(function()
|
|
||||||
current()
|
|
||||||
callCounter += 1
|
|
||||||
end)
|
|
||||||
|
|
||||||
bench.run("unstable 50%", function()
|
|
||||||
head(1)
|
|
||||||
assert(current() == 10)
|
|
||||||
callCounter = 0
|
|
||||||
for i = 0, 99 do
|
|
||||||
head(i)
|
|
||||||
end
|
|
||||||
assert(callCounter == 100)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local head = signal(0)
|
|
||||||
local last: () -> number = head
|
|
||||||
local callCounter = 0
|
|
||||||
|
|
||||||
for i = 0, 49 do
|
|
||||||
local current = computed(function()
|
|
||||||
return head() + i
|
|
||||||
end)
|
|
||||||
local current2 = computed(function()
|
|
||||||
return current() + 1
|
|
||||||
end)
|
|
||||||
effect(function()
|
|
||||||
current2()
|
|
||||||
callCounter += 1
|
|
||||||
end)
|
|
||||||
last = current2
|
|
||||||
end
|
|
||||||
|
|
||||||
bench.run("broadPropagation", function()
|
|
||||||
head(1)
|
|
||||||
callCounter = 0
|
|
||||||
for i = 0, 49 do
|
|
||||||
head(i)
|
|
||||||
assert(last() == i + 50)
|
|
||||||
end
|
|
||||||
assert(callCounter == 50 * 50)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local heads = {}
|
|
||||||
for index = 0, 99 do
|
|
||||||
heads[index] = signal(0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local mux = computed(function()
|
|
||||||
local result = {}
|
|
||||||
for index = 0, 99 do
|
|
||||||
result[index] = heads[index]()
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
end)
|
|
||||||
|
|
||||||
local splited = {}
|
|
||||||
for index = 0, 99 do
|
|
||||||
local x = computed(function()
|
|
||||||
return mux()[index]
|
|
||||||
end)
|
|
||||||
splited[index] = computed(function()
|
|
||||||
return x() + 1
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, x in splited do
|
|
||||||
effect(function()
|
|
||||||
x()
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
bench.run("mux", function()
|
|
||||||
for i = 0, 9 do
|
|
||||||
heads[i](i)
|
|
||||||
assert(splited[i]() == i + 1)
|
|
||||||
end
|
|
||||||
for i = 0, 9 do
|
|
||||||
heads[i](i * 2)
|
|
||||||
assert(splited[i]() == i * 2 + 1)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
54
benches/patch.bench.luau
Normal file
54
benches/patch.bench.luau
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src)
|
||||||
|
|
||||||
|
local patch = CharmSync.patch
|
||||||
|
|
||||||
|
local USERS = 1000
|
||||||
|
|
||||||
|
local function generate()
|
||||||
|
local users = {}
|
||||||
|
|
||||||
|
for user = 1, USERS do
|
||||||
|
users[user] = {
|
||||||
|
coins = math.random(1, 2),
|
||||||
|
gems = math.random(1, 2),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return { users = users }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function update(state)
|
||||||
|
state = table.clone(state)
|
||||||
|
state.users = table.clone(state.users)
|
||||||
|
|
||||||
|
for user = 1, USERS do
|
||||||
|
local rng = math.random()
|
||||||
|
|
||||||
|
if rng < 1 / 3 then
|
||||||
|
continue
|
||||||
|
elseif rng < 2 / 3 then
|
||||||
|
state.users[user] = nil
|
||||||
|
else
|
||||||
|
state.users[user] = {
|
||||||
|
coins = math.random(1, 2),
|
||||||
|
gems = math.random(1, 2),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
ParameterGenerator = function()
|
||||||
|
local state = generate()
|
||||||
|
return state, update(state)
|
||||||
|
end,
|
||||||
|
Functions = {
|
||||||
|
diff = function(_, a, b)
|
||||||
|
patch.diff(a, b)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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
|
|
||||||
4
bin/build.sh
Normal file
4
bin/build.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
mkdir build
|
||||||
|
rojo build build.project.json -o build/charm.rbxm
|
||||||
19
bin/check.sh
Normal file
19
bin/check.sh
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
|
||||||
|
|
||||||
|
rojo sourcemap -o sourcemap.json
|
||||||
|
|
||||||
|
luau-lsp analyze \
|
||||||
|
--defs=bin/roblox.d.luau \
|
||||||
|
--flag:LuauFixIndexerSubtypingOrdering=true \
|
||||||
|
--flag:LuauInstantiateInSubtyping=true \
|
||||||
|
--sourcemap=sourcemap.json \
|
||||||
|
--ignore="**/node_modules/**" \
|
||||||
|
packages test benches
|
||||||
|
|
||||||
|
selene packages test benches
|
||||||
|
|
||||||
|
stylua --check packages test benches
|
||||||
|
|
||||||
|
pnpm eslint packages
|
||||||
|
|
||||||
|
rm bin/roblox.d.luau
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
"$className": "Folder",
|
"$className": "Folder",
|
||||||
"Charm": { "$path": "packages/charm/src" },
|
"Charm": { "$path": "packages/charm/src" },
|
||||||
"CharmSync": { "$path": "packages/charm-sync/src" },
|
"CharmSync": { "$path": "packages/charm-sync/src" },
|
||||||
"DeepCharm": { "$path": "packages/deep-charm/src" },
|
|
||||||
"ReactCharm": { "$path": "packages/react-charm/src" },
|
"ReactCharm": { "$path": "packages/react-charm/src" },
|
||||||
"VideCharm": { "$path": "packages/vide-charm/src" }
|
"VideCharm": { "$path": "packages/vide-charm/src" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,25 +19,16 @@
|
||||||
},
|
},
|
||||||
"charm": {
|
"charm": {
|
||||||
"$className": "Folder",
|
"$className": "Folder",
|
||||||
"src": { "$path": "packages/charm/src" },
|
"src": { "$path": "packages/charm/src" }
|
||||||
"test": { "$path": "packages/charm/test" }
|
|
||||||
},
|
|
||||||
"deep-charm": {
|
|
||||||
"$className": "Folder",
|
|
||||||
"src": { "$path": "packages/deep-charm/src" },
|
|
||||||
"test": { "$path": "packages/deep-charm/test" },
|
|
||||||
"Charm": { "$path": "packages/deep-charm/Charm.luau" }
|
|
||||||
},
|
},
|
||||||
"charm-sync": {
|
"charm-sync": {
|
||||||
"$className": "Folder",
|
"$className": "Folder",
|
||||||
"src": { "$path": "packages/charm-sync/src" },
|
"src": { "$path": "packages/charm-sync/src" },
|
||||||
"test": { "$path": "packages/charm-sync/test" },
|
|
||||||
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
|
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
|
||||||
},
|
},
|
||||||
"react-charm": {
|
"react-charm": {
|
||||||
"$className": "Folder",
|
"$className": "Folder",
|
||||||
"src": { "$path": "packages/react-charm/src" },
|
"src": { "$path": "packages/react-charm/src" },
|
||||||
"test": { "$path": "packages/react-charm/test" },
|
|
||||||
"React": { "$path": "packages/react-charm/React.luau" },
|
"React": { "$path": "packages/react-charm/React.luau" },
|
||||||
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
|
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
|
||||||
"Charm": { "$path": "packages/react-charm/Charm.luau" }
|
"Charm": { "$path": "packages/react-charm/Charm.luau" }
|
||||||
|
|
@ -45,7 +36,6 @@
|
||||||
"vide-charm": {
|
"vide-charm": {
|
||||||
"$className": "Folder",
|
"$className": "Folder",
|
||||||
"src": { "$path": "packages/vide-charm/src" },
|
"src": { "$path": "packages/vide-charm/src" },
|
||||||
"test": { "$path": "packages/vide-charm/test" },
|
|
||||||
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
|
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
|
||||||
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
|
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +43,8 @@
|
||||||
"@rbxts-js": {
|
"@rbxts-js": {
|
||||||
"$path": "node_modules/@rbxts-js"
|
"$path": "node_modules/@rbxts-js"
|
||||||
},
|
},
|
||||||
"tests": {
|
"test": {
|
||||||
"$path": "tests"
|
"$path": "test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
20
eslint.config.ts
Normal file
20
eslint.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import eslint from "@eslint/js";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import prettierRecommended from "eslint-plugin-prettier/recommended";
|
||||||
|
import roblox from "eslint-plugin-roblox-ts";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
roblox.configs.tsRecommendedCompat,
|
||||||
|
roblox.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"roblox-ts/no-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globalIgnores(["out/**"]),
|
||||||
|
prettierRecommended,
|
||||||
|
);
|
||||||
35
package.json
35
package.json
|
|
@ -1,29 +1,30 @@
|
||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "lute run test",
|
"test": "lune run test/spec.server.luau",
|
||||||
"build": "lute run build",
|
"build": "sh bin/build.sh",
|
||||||
"check": "lute run check",
|
"check": "sh bin/check.sh",
|
||||||
"publish:npm": "pnpm publish -r --access public",
|
"publish:npm": "pnpm publish -r --access public",
|
||||||
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
|
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@12.8.1",
|
"packageManager": "pnpm@10.22.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.36.0",
|
||||||
"@rbxts/compiler-types": "3.0.0-types.0",
|
"@rbxts/compiler-types": "3.0.0-types.0",
|
||||||
"@rbxts/react": "17.3.7-ts.1",
|
"@rbxts/react": "17.3.5-alpha.1",
|
||||||
"@rbxts/react-roblox": "17.3.7-ts.1",
|
"@rbxts/react-roblox": "17.3.5-alpha.1",
|
||||||
"@rbxts/types": "^1.0.913",
|
"@rbxts/types": "^1.0.883",
|
||||||
"@rbxts/vide": "^0.6.1",
|
"@rbxts/vide": "^0.5.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||||
"@typescript-eslint/parser": "^8.58.0",
|
"@typescript-eslint/parser": "^8.44.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.36.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-roblox-ts": "^1.4.0",
|
"eslint-plugin-roblox-ts": "^1.2.1",
|
||||||
"prettier": "^3.8.1",
|
"jiti": "^2.6.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"roblox-ts": "^3.0.0",
|
"roblox-ts": "^3.0.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.58.0"
|
"typescript-eslint": "^8.44.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ end
|
||||||
|
|
||||||
local Charm = require(script.Parent.Parent.charm.src)
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
export type Atom<T> = Charm.Atom<T>
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
||||||
return Charm
|
return Charm
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/charm-sync",
|
"name": "@rbxts/charm-sync",
|
||||||
"version": "0.4.1",
|
"version": "0.4.0-rc.1",
|
||||||
"description": "Simple server-to-client sync for Charm",
|
"description": "Simple server-to-client sync for Charm",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "src/init.luau",
|
"main": "src/init.luau",
|
||||||
|
|
@ -11,17 +11,17 @@
|
||||||
"!wally.toml",
|
"!wally.toml",
|
||||||
"!wally.lock",
|
"!wally.lock",
|
||||||
"!default.project.json",
|
"!default.project.json",
|
||||||
"!test"
|
"!**/__tests__"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/littensy/charm.git"
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
"publish:wally": "wally publish"
|
"publish:wally": "wally publish"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rbxts/charm": "workspace:^"
|
"@rbxts/charm": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local DeepCharm = require("../../deep-charm/src")
|
local client = require("../client")
|
||||||
local client = require("../src/client")
|
local suite = require("../../../../test/suite")
|
||||||
local test = require("../../../tests/test")
|
|
||||||
|
|
||||||
test.afterEach(function()
|
local test = suite.test
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
client.removeAllSignals()
|
client.removeAllSignals()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -38,31 +40,6 @@ test("should merge patch", function()
|
||||||
assert(a().c == 2, "should not change c")
|
assert(a().c == 2, "should not change c")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should merge patch (reactive)", function()
|
|
||||||
local a = DeepCharm.reactive({ b = 1, c = 2 })
|
|
||||||
local runs = 0
|
|
||||||
|
|
||||||
Charm.effect(function()
|
|
||||||
local _ = a.b + a.c
|
|
||||||
runs += 1
|
|
||||||
end)
|
|
||||||
|
|
||||||
client.addSignals({ a = a })
|
|
||||||
client.patch({
|
|
||||||
{ type = "init", data = { a = { b = a.b, c = a.c } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
assert(runs == 1, "should not trigger effect for redundant init")
|
|
||||||
|
|
||||||
client.patch({
|
|
||||||
{ type = "patch", data = { a = { b = 3 } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
assert(a.b == 3, "should set b to the new value")
|
|
||||||
assert(a.c == 2, "should not change c")
|
|
||||||
assert(runs == 2, "should trigger effect for patch")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should fix sparse array patches", function()
|
test("should fix sparse array patches", function()
|
||||||
local a = Charm.atom({ 1 })
|
local a = Charm.atom({ 1 })
|
||||||
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local DeepCharm = require("../../deep-charm/src")
|
local client = require("../client")
|
||||||
local client = require("../src/client")
|
local config = require("../config")
|
||||||
local config = require("../src/config")
|
local server = require("../server")
|
||||||
local server = require("../src/server")
|
local suite = require("../../../../test/suite")
|
||||||
local test = require("../../../tests/test")
|
|
||||||
|
|
||||||
test.afterEach(function()
|
local afterEach = suite.afterEach
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
server.reset()
|
server.reset()
|
||||||
client.removeAllSignals()
|
client.removeAllSignals()
|
||||||
config.preserveHistory = false
|
config.preserveHistory = false
|
||||||
|
|
@ -14,9 +16,9 @@ end)
|
||||||
test("should replicate server state to client", function()
|
test("should replicate server state to client", function()
|
||||||
local target = {}
|
local target = {}
|
||||||
local serverA = Charm.atom({ count = 0 })
|
local serverA = Charm.atom({ count = 0 })
|
||||||
local serverB = DeepCharm.reactive({ count = 0 })
|
local serverB = Charm.atom({ count = 0 })
|
||||||
local clientA = Charm.atom({ count = -1 })
|
local clientA = Charm.atom({ count = -1 })
|
||||||
local clientB = DeepCharm.reactive({ count = -1 })
|
local clientB = Charm.atom({ count = -1 })
|
||||||
|
|
||||||
client.addSignals({ a = clientA, b = clientB })
|
client.addSignals({ a = clientA, b = clientB })
|
||||||
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||||
|
|
@ -28,32 +30,27 @@ test("should replicate server state to client", function()
|
||||||
|
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(clientA().count == 0, "Client atom A should be initialized to 0")
|
assert(clientA().count == 0, "Client atom A should be initialized to 0")
|
||||||
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
|
assert(clientB().count == 0, "Client atom B should be initialized to 0")
|
||||||
|
|
||||||
serverA({ count = 1 })
|
serverA({ count = 1 })
|
||||||
serverB.count = 1
|
serverB({ count = 1 })
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(clientA().count == 1, "Client atom A should be updated to 1")
|
assert(clientA().count == 1, "Client atom A should be updated to 1")
|
||||||
assert(clientB.count == 1, "Client reactive B should be updated to 1")
|
assert(clientB().count == 1, "Client atom B should be updated to 1")
|
||||||
|
|
||||||
local runs = -1
|
|
||||||
Charm.effect(function()
|
|
||||||
local _ = clientB.count
|
|
||||||
runs += 1
|
|
||||||
end)
|
|
||||||
|
|
||||||
|
local prevClientB = clientB()
|
||||||
serverA({ count = 2 })
|
serverA({ count = 2 })
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||||
assert(runs == 0, "Client reactive B should not trigger update")
|
assert(clientB() == prevClientB, "Client atom B should not be updated")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should replicate historical server state to client", function()
|
test("should replicate historical server state to client", function()
|
||||||
local target = {}
|
local target = {}
|
||||||
local serverA = Charm.atom({ count = 0 })
|
local serverA = Charm.atom({ count = 0 })
|
||||||
local serverB = DeepCharm.reactive({ count = 0 })
|
local serverB = Charm.atom({ count = 0 })
|
||||||
local clientA = Charm.atom({ count = -1 })
|
local clientA = Charm.atom({ count = -1 })
|
||||||
local clientB = DeepCharm.reactive({ count = -1 })
|
local clientB = Charm.atom({ count = -1 })
|
||||||
|
|
||||||
config.preserveHistory = true
|
config.preserveHistory = true
|
||||||
|
|
||||||
|
|
@ -70,28 +67,30 @@ test("should replicate historical server state to client", function()
|
||||||
|
|
||||||
Charm.effect(function()
|
Charm.effect(function()
|
||||||
local expected = table.remove(expectedA, 1)
|
local expected = table.remove(expectedA, 1)
|
||||||
assert(expected == clientA().count, `Client reactive A should be {expected}`)
|
assert(expected == clientA().count, `Client atom A should be {expected}`)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Charm.effect(function()
|
Charm.effect(function()
|
||||||
local expected = table.remove(expectedB, 1)
|
local expected = table.remove(expectedB, 1)
|
||||||
assert(expected == clientB.count, `Client reactive B should be {expected}`)
|
assert(expected == clientB().count, `Client atom B should be {expected}`)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
server.flush()
|
server.flush()
|
||||||
|
|
||||||
serverA({ count = 1 })
|
serverA({ count = 1 })
|
||||||
serverA({ count = 2 })
|
serverA({ count = 2 })
|
||||||
serverB.count = 1
|
serverB({ count = 1 })
|
||||||
serverB.count = 2
|
serverB({ count = 2 })
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||||
assert(clientB.count == 2, "Client reactive B should be updated to 2")
|
assert(clientB().count == 2, "Client atom B should be updated to 2")
|
||||||
|
|
||||||
|
local prevClientB = clientB()
|
||||||
serverA({ count = 3 })
|
serverA({ count = 3 })
|
||||||
serverA({ count = 4 })
|
serverA({ count = 4 })
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
||||||
|
assert(clientB() == prevClientB, "Client atom B should not be updated")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
local config = require("../src/config")
|
local config = require("../config")
|
||||||
local patch = require("../src/patch")
|
local patch = require("../patch")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||||
if object[1] then
|
if object[1] then
|
||||||
|
|
@ -20,7 +23,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test.afterEach(function()
|
afterEach(function()
|
||||||
config.fixArrays = true
|
config.fixArrays = true
|
||||||
config.validatePatches = true
|
config.validatePatches = true
|
||||||
end)
|
end)
|
||||||
|
|
@ -28,17 +31,17 @@ end)
|
||||||
test("returns a patch", function()
|
test("returns a patch", function()
|
||||||
local prevState = { foo = "bar", baz = "qux" }
|
local prevState = { foo = "bar", baz = "qux" }
|
||||||
local nextState = { foo = "bar", baz = "quux" }
|
local nextState = { foo = "bar", baz = "quux" }
|
||||||
local diffs = patch.diff(prevState, nextState)
|
local patches = patch.diff(prevState, nextState)
|
||||||
|
|
||||||
assert(diffs ~= nil, "should return a patch")
|
assert(patches ~= nil, "should return a patch")
|
||||||
assert(diffs.baz == "quux", "should return the changed value")
|
assert(patches.baz == "quux", "should return the changed value")
|
||||||
assert(diffs.foo == nil, "should omit unchanged values")
|
assert(patches.foo == nil, "should omit unchanged values")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("applies a patch", function()
|
test("applies a patch", function()
|
||||||
local prevState = { foo = "bar", baz = "qux" }
|
local prevState = { foo = "bar", baz = "qux" }
|
||||||
local diffs = { baz = "quux", qaz = "qux" }
|
local patches = { baz = "quux", qaz = "qux" }
|
||||||
local applied = patch.apply(prevState, diffs)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
assert(applied ~= nil, "should return a new state")
|
assert(applied ~= nil, "should return a new state")
|
||||||
assert(applied.baz == "quux", "should apply the changed value")
|
assert(applied.baz == "quux", "should apply the changed value")
|
||||||
|
|
@ -46,11 +49,11 @@ test("applies a patch", function()
|
||||||
assert(applied.qaz == "qux", "should add the new value")
|
assert(applied.qaz == "qux", "should add the new value")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("allows nested diffs", function()
|
test("allows nested patches", function()
|
||||||
local prevState = { foo = { bar = "baz" } }
|
local prevState = { foo = { bar = "baz" } }
|
||||||
local nextState = { foo = { bar = "qux" } }
|
local nextState = { foo = { bar = "qux" } }
|
||||||
local diffs = patch.diff(prevState, nextState)
|
local patches = patch.diff(prevState, nextState)
|
||||||
local applied = patch.apply(prevState, diffs)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
assert(applied.foo.bar == "qux", "should apply the nested patch")
|
assert(applied.foo.bar == "qux", "should apply the nested patch")
|
||||||
end)
|
end)
|
||||||
|
|
@ -75,8 +78,8 @@ test("preserves array density after serdes", function()
|
||||||
local prevState = { "a", "b", "c", "d", "e" }
|
local prevState = { "a", "b", "c", "d", "e" }
|
||||||
local nextState = { "A", "b", "c", "D" }
|
local nextState = { "A", "b", "c", "D" }
|
||||||
|
|
||||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
local applied = patch.apply(prevState, diffs)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
for index in prevState do
|
for index in prevState do
|
||||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||||
|
|
@ -93,10 +96,10 @@ test("cannot preserve sparse arrays", function()
|
||||||
local prevState = {}
|
local prevState = {}
|
||||||
local nextState = { [2] = "foo" }
|
local nextState = { [2] = "foo" }
|
||||||
|
|
||||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
local applied = patch.apply(prevState, diffs)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
-- Not enough information is available to determine whether 'diffs'
|
-- Not enough information is available to determine whether 'patches'
|
||||||
-- should keep the string keys added during remote event serialization.
|
-- should keep the string keys added during remote event serialization.
|
||||||
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
|
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
|
||||||
assert(applied[2] == nil, "will fail to convert string keys to numbers")
|
assert(applied[2] == nil, "will fail to convert string keys to numbers")
|
||||||
|
|
@ -116,18 +119,20 @@ test("optionally fixes sparse arrays", function()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("patches mutable state", function()
|
test("optionally validates state", function()
|
||||||
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
|
local success = pcall(function()
|
||||||
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
|
config.validatePatches = true
|
||||||
assert(targetState.baz == "quux", "should apply the changed value")
|
patch.diff({}, { [1] = 1, a = true })
|
||||||
assert(targetState.foo == "bar", "should keep the unchanged value")
|
end)
|
||||||
assert(targetState.qaz == "qux", "should add the new value")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("removes table from mutable state", function()
|
assert(not success, "should not allow mixed tables")
|
||||||
local targetState = { foo = { bar = "baz" } }
|
|
||||||
patch.applyMutable(targetState, { foo = { __none = "__none" } })
|
success = pcall(function()
|
||||||
assert(targetState.foo == nil, "should remove the table")
|
config.validatePatches = false
|
||||||
|
patch.diff({}, { [1] = 1, a = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(success, "should skip validation if not requested")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local DeepCharm = require("../../deep-charm/src")
|
local config = require("../config")
|
||||||
local config = require("../src/config")
|
local patch = require("../patch")
|
||||||
local patch = require("../src/patch")
|
local server = require("../server")
|
||||||
local server = require("../src/server")
|
local suite = require("../../../../test/suite")
|
||||||
local test = require("../../../tests/test")
|
|
||||||
local types = require("../src/types")
|
|
||||||
|
|
||||||
test.afterEach(function()
|
local afterEach = suite.afterEach
|
||||||
|
local beforeEach = suite.beforeEach
|
||||||
|
local describe = suite.describe
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
server.reset()
|
server.reset()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -15,7 +18,7 @@ test("should replicate state changes", function()
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local c = Charm.atom(0)
|
local c = Charm.atom(0)
|
||||||
local target: unknown, payloads: { types.SyncPayload }
|
local target, payloads
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||||
server.connect(function(...)
|
server.connect(function(...)
|
||||||
|
|
@ -42,7 +45,7 @@ test("should replicate partial state patches", function()
|
||||||
local client = {}
|
local client = {}
|
||||||
local a = Charm.atom({ x = 0, y = 0 })
|
local a = Charm.atom({ x = 0, y = 0 })
|
||||||
local b = Charm.atom(0 :: number?)
|
local b = Charm.atom(0 :: number?)
|
||||||
local target: unknown, payloads: { types.SyncPayload }
|
local target, payloads
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a, b = b })
|
server.addSignalsToClient(client, { a = a, b = b })
|
||||||
server.connect(function(...)
|
server.connect(function(...)
|
||||||
|
|
@ -66,60 +69,11 @@ test("should replicate partial state patches", function()
|
||||||
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
|
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should replicate partial state patches (reactive)", function()
|
|
||||||
local client = {}
|
|
||||||
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
|
|
||||||
local target: unknown, payloads: { types.SyncPayload }
|
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a })
|
|
||||||
server.connect(function(...)
|
|
||||||
target, payloads = ...
|
|
||||||
end)
|
|
||||||
|
|
||||||
server.flush()
|
|
||||||
assert(target == client, "Expected target to be the client")
|
|
||||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
|
||||||
assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'")
|
|
||||||
assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
|
|
||||||
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
|
|
||||||
|
|
||||||
a.x.y = 1
|
|
||||||
a.z = nil
|
|
||||||
server.flush()
|
|
||||||
assert(target == client, "Expected target to be the client")
|
|
||||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
|
||||||
assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'")
|
|
||||||
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
|
|
||||||
|
|
||||||
a.z = 1
|
|
||||||
server.flush()
|
|
||||||
assert(target == client, "Expected target to be the client")
|
|
||||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
|
||||||
assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'")
|
|
||||||
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should handle nested proxies", function()
|
|
||||||
local client = {}
|
|
||||||
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
|
|
||||||
local payloads: {}? = nil
|
|
||||||
|
|
||||||
server.addSignalsToClient(client, { src = src })
|
|
||||||
server.connect(function(_, newPayloads)
|
|
||||||
payloads = newPayloads
|
|
||||||
end)
|
|
||||||
server.flush()
|
|
||||||
|
|
||||||
payloads = nil
|
|
||||||
server.flush()
|
|
||||||
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should init new clients", function()
|
test("should init new clients", function()
|
||||||
local clientA = {}
|
local clientA = {}
|
||||||
local clientB = {}
|
local clientB = {}
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
server.addSignalsToClient(clientA, { a = a })
|
server.addSignalsToClient(clientA, { a = a })
|
||||||
server.connect(function(target, payloads)
|
server.connect(function(target, payloads)
|
||||||
|
|
@ -135,8 +89,8 @@ test("should init new clients", function()
|
||||||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||||
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||||
|
|
||||||
a(2)
|
|
||||||
server.addSignalsToClient(clientB, { a = a })
|
server.addSignalsToClient(clientB, { a = a })
|
||||||
|
a(2)
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||||
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
||||||
|
|
@ -148,7 +102,7 @@ test("should init new atoms", function()
|
||||||
local client = {}
|
local client = {}
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local target: unknown, payloads: { types.SyncPayload }
|
local target, payloads
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a })
|
server.addSignalsToClient(client, { a = a })
|
||||||
server.connect(function(...)
|
server.connect(function(...)
|
||||||
|
|
@ -173,7 +127,7 @@ test("should only replicate to synced clients", function()
|
||||||
local clientB = {}
|
local clientB = {}
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
server.addSignalsToClient(clientA, { a = a })
|
server.addSignalsToClient(clientA, { a = a })
|
||||||
server.addSignalsToClient(clientB, { b = b })
|
server.addSignalsToClient(clientB, { b = b })
|
||||||
|
|
@ -212,10 +166,24 @@ test("should only replicate to synced clients", function()
|
||||||
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
|
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
test("should validate state", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom({})
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a })
|
||||||
|
server.connect(function() end)
|
||||||
|
|
||||||
|
a({ [0.5] = true })
|
||||||
|
assert(not pcall(server.flush), "Expected server.flush to fail")
|
||||||
|
|
||||||
|
a({ [0.5] = true, valid = true })
|
||||||
|
server.flush()
|
||||||
|
end)
|
||||||
|
|
||||||
test("should serialize state", function()
|
test("should serialize state", function()
|
||||||
local client = {}
|
local client = {}
|
||||||
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||||
local payloads: { types.SyncPayload }
|
local payloads
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a })
|
server.addSignalsToClient(client, { a = a })
|
||||||
server.connect(function(_, p)
|
server.connect(function(_, p)
|
||||||
|
|
@ -229,32 +197,12 @@ test("should serialize state", function()
|
||||||
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
|
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should not send unchanged state", function()
|
describe("historical", function()
|
||||||
local client = {}
|
beforeEach(function()
|
||||||
local a = Charm.atom(0)
|
|
||||||
local payloads: { types.SyncPayload }
|
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a })
|
|
||||||
server.connect(function(_, p)
|
|
||||||
payloads = p
|
|
||||||
end)
|
|
||||||
|
|
||||||
server.flush()
|
|
||||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
|
||||||
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
|
||||||
|
|
||||||
payloads = {}
|
|
||||||
a(0)
|
|
||||||
server.flush()
|
|
||||||
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.describe("historical", function()
|
|
||||||
test.beforeEach(function()
|
|
||||||
config.preserveHistory = true
|
config.preserveHistory = true
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test.afterEach(function()
|
afterEach(function()
|
||||||
config.preserveHistory = false
|
config.preserveHistory = false
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -263,7 +211,7 @@ test.describe("historical", function()
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local c = Charm.atom(0)
|
local c = Charm.atom(0)
|
||||||
local target: unknown, payloads: { types.SyncPayload }
|
local target, payloads
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||||
server.connect(function(...)
|
server.connect(function(...)
|
||||||
|
|
@ -288,41 +236,11 @@ test.describe("historical", function()
|
||||||
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
|
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should replicate granular state changes (reactive)", function()
|
|
||||||
local client = {}
|
|
||||||
local a = DeepCharm.reactive({ value = 0 })
|
|
||||||
local b = DeepCharm.reactive({ value = 0 })
|
|
||||||
local c = DeepCharm.reactive({ value = 0 })
|
|
||||||
local target: unknown, payloads: { types.SyncPayload }
|
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
|
||||||
server.connect(function(...)
|
|
||||||
target, payloads = ...
|
|
||||||
end)
|
|
||||||
|
|
||||||
server.flush()
|
|
||||||
assert(target == client, "Expected target to be the client")
|
|
||||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
|
||||||
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
|
|
||||||
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
|
|
||||||
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
|
|
||||||
|
|
||||||
a.value = 1
|
|
||||||
a.value = 2
|
|
||||||
b.value = 2
|
|
||||||
server.flush()
|
|
||||||
assert(target == client, "Expected target to be the client")
|
|
||||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
|
||||||
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
|
|
||||||
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
|
|
||||||
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should init new clients", function()
|
test("should init new clients", function()
|
||||||
local clientA = {}
|
local clientA = {}
|
||||||
local clientB = {}
|
local clientB = {}
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
server.addSignalsToClient(clientA, { a = a })
|
server.addSignalsToClient(clientA, { a = a })
|
||||||
server.connect(function(target, payloads)
|
server.connect(function(target, payloads)
|
||||||
|
|
@ -358,7 +276,7 @@ test.describe("historical", function()
|
||||||
local client = {}
|
local client = {}
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local payloads: { types.SyncPayload }
|
local payloads
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a })
|
server.addSignalsToClient(client, { a = a })
|
||||||
server.connect(function(_, ...)
|
server.connect(function(_, ...)
|
||||||
|
|
@ -383,74 +301,6 @@ test.describe("historical", function()
|
||||||
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
|
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
|
||||||
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
|
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should not send unchanged state", function()
|
|
||||||
local client = {}
|
|
||||||
local a = Charm.atom(0)
|
|
||||||
local payloads: { types.SyncPayload }
|
|
||||||
|
|
||||||
server.addSignalsToClient(client, { a = a })
|
|
||||||
server.connect(function(_, p)
|
|
||||||
payloads = p
|
|
||||||
end)
|
|
||||||
|
|
||||||
server.flush()
|
|
||||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
|
||||||
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
|
||||||
|
|
||||||
payloads = {}
|
|
||||||
a(0)
|
|
||||||
server.flush()
|
|
||||||
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should maintain patch list order for stale update", function()
|
|
||||||
local a = Charm.atom({ x = 1 })
|
|
||||||
local b = Charm.atom({ y = 1 })
|
|
||||||
local payloads
|
|
||||||
|
|
||||||
server.connect(function(_client, _payloads)
|
|
||||||
payloads = _payloads
|
|
||||||
end)
|
|
||||||
|
|
||||||
server.addSignalsToClient("client", { a = a, b = b })
|
|
||||||
server.flush()
|
|
||||||
|
|
||||||
payloads = nil
|
|
||||||
a({ x = 1 }) -- new table, same contents: diff is nil
|
|
||||||
b({ y = 2 })
|
|
||||||
server.flush()
|
|
||||||
assert(payloads, "Expected payloads to be sent")
|
|
||||||
assert(payloads[1].type == "patch", "Expected payload to be patch")
|
|
||||||
assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'")
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("addSignalsToClient should ignore tracking context", function()
|
|
||||||
local counter = Charm.atom(0)
|
|
||||||
local payloads: { any }?
|
|
||||||
|
|
||||||
server.connect(function(_client, _payloads)
|
|
||||||
payloads = _payloads
|
|
||||||
end)
|
|
||||||
|
|
||||||
local dispose = Charm.effectScope(function()
|
|
||||||
server.addSignalsToClient("PLAYER_ID", { counter = counter })
|
|
||||||
end)
|
|
||||||
|
|
||||||
server.flush()
|
|
||||||
assert(payloads, "Expected payloads to be sent")
|
|
||||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
|
||||||
assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'")
|
|
||||||
|
|
||||||
dispose()
|
|
||||||
|
|
||||||
payloads = nil
|
|
||||||
counter(1)
|
|
||||||
server.flush()
|
|
||||||
assert(payloads, "Expected payloads to be sent")
|
|
||||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
|
||||||
assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'")
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
27
packages/charm-sync/src/__tests__/spec.luau
Normal file
27
packages/charm-sync/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
suite.describe("client", function()
|
||||||
|
require("./client.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("e2e", function()
|
||||||
|
require("./e2e.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("utils", function()
|
||||||
|
require("./utils.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("patch", function()
|
||||||
|
require("./patch.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("server", function()
|
||||||
|
require("./server.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("validate", function()
|
||||||
|
require("./validate.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
local utils = require("../src/utils")
|
local utils = require("../utils")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should flatten functions", function()
|
test("should flatten functions", function()
|
||||||
local input = {
|
local input = {
|
||||||
a = Charm.atom(0),
|
a = Charm.atom(0),
|
||||||
b = { c = Charm.atom(0) },
|
b = { c = Charm.atom(0) },
|
||||||
d = { e = { f = utils.signalToAtom<<number>>(Charm.signal(0)) } },
|
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
|
||||||
}
|
}
|
||||||
|
local result = utils.flatten(input)
|
||||||
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
|
|
||||||
|
|
||||||
assert(result.a == input.a, "should flatten the first level")
|
assert(result.a == input.a, "should flatten the first level")
|
||||||
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
||||||
24
packages/charm-sync/src/__tests__/validate.spec.luau
Normal file
24
packages/charm-sync/src/__tests__/validate.spec.luau
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
local validate = require("../validate")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("validates inputs", function()
|
||||||
|
local cases: { [string]: { value: any, expected: any } } = {
|
||||||
|
array = { value = { 1, 2, 3 }, expected = true },
|
||||||
|
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||||
|
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||||
|
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, case in cases do
|
||||||
|
local success, result: any = pcall(validate, case.value)
|
||||||
|
if case.expected and not success then
|
||||||
|
error(`Expected {key} to be valid, threw {result}`)
|
||||||
|
elseif not case.expected and success then
|
||||||
|
error(`Expected {key} to be invalid, but it was valid`)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -3,98 +3,66 @@ local patch = require("./patch")
|
||||||
local types = require("./types")
|
local types = require("./types")
|
||||||
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
|
|
||||||
|
|
||||||
local trackedSignals: { [string]: (any) -> any } = {}
|
type Setter<T = any> = (update: ((T) -> T) | T) -> ()
|
||||||
local trackedReactives: { [string]: ReactiveProxy } = {}
|
|
||||||
|
|
||||||
local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
|
local trackedSetters: { [string]: Setter } = {}
|
||||||
local target = reactive.__target
|
|
||||||
for key, value in source do
|
|
||||||
target[key] = value
|
|
||||||
end
|
|
||||||
Charm.trigger(reactive.__track)
|
|
||||||
end
|
|
||||||
|
|
||||||
--[[
|
--- Registers a map of setters to the state with the corresponding keys on the
|
||||||
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
|
||||||
server. When the server sends updates, these setters will be called with
|
--- the new values.
|
||||||
the new values.
|
---
|
||||||
|
--- Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||||
Atoms, signals converted to atoms, and signal setter functions are allowed.
|
--- Note that `server.addSignalsToClient` still requires some way to get the
|
||||||
Note that `server.addSignalsToClient` still requires some way to get the
|
--- state.
|
||||||
state.
|
---
|
||||||
|
--- @param setters A map of setter functions to sync with the server.
|
||||||
@param signals A map of setter functions to sync with the server.
|
local function addSignals(setters: { [string]: Setter })
|
||||||
]]
|
for key, setter in setters do
|
||||||
local function addSignals(signals: { [string]: ((any) -> any) | {} })
|
trackedSetters[key] = setter
|
||||||
for key, signal in signals do
|
|
||||||
if type(signal) == "function" then
|
|
||||||
trackedSignals[key] = signal
|
|
||||||
else
|
|
||||||
trackedReactives[key] = (signal :: any) :: ReactiveProxy
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Unregisters from server state updates for the given keys. The signals
|
||||||
Unregisters from server state updates for the given keys. The signals
|
--- will retain their current values, but will no longer receive updates
|
||||||
will retain their current values, but will no longer receive updates
|
--- from the server.
|
||||||
from the server.
|
|
||||||
]]
|
|
||||||
local function removeSignals(...: string)
|
local function removeSignals(...: string)
|
||||||
for index = 1, select("#", ...) do
|
for index = 1, select("#", ...) do
|
||||||
local key = select(index, ...)
|
local key = select(index, ...)
|
||||||
trackedSignals[key] = nil
|
trackedSetters[key] = nil
|
||||||
trackedReactives[key] = nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Unsubscribes from all server state updates.
|
||||||
Unsubscribes from all server state updates.
|
|
||||||
]]
|
|
||||||
local function removeAllSignals()
|
local function removeAllSignals()
|
||||||
table.clear(trackedSignals)
|
table.clear(trackedSetters)
|
||||||
table.clear(trackedReactives)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function applyInitPayload(data: { [string]: any })
|
local function applyInitPayload(data: { [string]: any })
|
||||||
for key, value in data do
|
for key, value in data do
|
||||||
if trackedSignals[key] then
|
local setter = trackedSetters[key]
|
||||||
trackedSignals[key](value)
|
if setter then
|
||||||
elseif trackedReactives[key] then
|
setter(value)
|
||||||
shallowMergeReactive(trackedReactives[key], value)
|
|
||||||
else
|
|
||||||
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function applyPatchPayload(data: { [string]: any })
|
local function applyPatchPayload(data: { [string]: any })
|
||||||
for key, diffs in data do
|
for key, value in data do
|
||||||
local trackedSignal = trackedSignals[key]
|
local setter = trackedSetters[key]
|
||||||
local trackedReactive = trackedReactives[key]
|
if setter then
|
||||||
|
setter(function(current)
|
||||||
if trackedSignal then
|
return patch.apply(current, value)
|
||||||
trackedSignal(function(value)
|
|
||||||
return patch.apply(value, diffs)
|
|
||||||
end)
|
end)
|
||||||
elseif trackedReactive then
|
|
||||||
patch.applyMutable(trackedReactive.__target, diffs)
|
|
||||||
Charm.trigger(trackedReactive.__track)
|
|
||||||
else
|
|
||||||
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Merges the server's state patches into the client's state, either
|
||||||
Merges the server's state patches into the client's state, either
|
--- initializing it with the full state or merging a partial patch.
|
||||||
initializing it with the full state or merging a partial patch.
|
---
|
||||||
|
--- @param payloads The state updates received from the server.
|
||||||
@param payloads The state updates received from the server.
|
local function patch(payloads: { SyncPayload })
|
||||||
]]
|
|
||||||
local function applyPatches(payloads: { SyncPayload })
|
|
||||||
for _, payload in payloads do
|
for _, payload in payloads do
|
||||||
if payload.type == "init" then
|
if payload.type == "init" then
|
||||||
Charm.batch(applyInitPayload, payload.data)
|
Charm.batch(applyInitPayload, payload.data)
|
||||||
|
|
@ -108,5 +76,5 @@ return {
|
||||||
addSignals = addSignals,
|
addSignals = addSignals,
|
||||||
removeSignals = removeSignals,
|
removeSignals = removeSignals,
|
||||||
removeAllSignals = removeAllSignals,
|
removeAllSignals = removeAllSignals,
|
||||||
patch = applyPatches,
|
patch = patch,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,20 @@
|
||||||
return {
|
return {
|
||||||
--[[
|
--- The interval at which to send patches to the client, in seconds.
|
||||||
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
|
||||||
Defaults to `0` (patches are sent up to once per frame). Set to a
|
--- negative value to disable automatic syncing.
|
||||||
negative value to disable automatic syncing.
|
|
||||||
]]
|
|
||||||
interval = 0,
|
interval = 0,
|
||||||
--[[
|
--- Whether the history of state changes since the client's last update
|
||||||
Whether the history of state changes since the client's last update
|
--- should be preserved. This is useful for values that change multiple
|
||||||
should be preserved. This is useful for values that change multiple
|
--- times per frame, where each individual change is important. Defaults
|
||||||
times per frame, where each individual change is important. Defaults
|
--- to `false`.
|
||||||
to `false` for performance reasons.
|
|
||||||
]]
|
|
||||||
preserveHistory = false,
|
preserveHistory = false,
|
||||||
--[[
|
--- When `true`, Charm will apply validation and serialize unsafe arrays
|
||||||
When `true`, Charm will modify sparse arrays to prevent data loss from
|
--- to address remote event argument limitations. Defaults to `true`.
|
||||||
remote event limitations. When an unsafe array is detected, numeric
|
--- This option should be disabled if your network library uses a custom
|
||||||
indices will be converted to string keys so that the array can be sent
|
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||||
safely and then converted back to an array on the receiving end.
|
|
||||||
|
|
||||||
This option should be disabled if your network library uses a custom
|
|
||||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
|
||||||
]]
|
|
||||||
fixArrays = true,
|
fixArrays = true,
|
||||||
--[[
|
--- When `true`, synced state containing unsafe sparse arrays or mixed
|
||||||
When `true`, synced state containing unsafe sparse arrays or mixed
|
--- tables will throw an error. Only checked in strict mode and if
|
||||||
tables will emit a warning. Only checked in strict mode and if
|
--- `fixArrays` is enabled. Defaults to `true`.
|
||||||
`fixArrays` is enabled. Defaults to `true`.
|
|
||||||
]]
|
|
||||||
validatePatches = true,
|
validatePatches = true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
packages/charm-sync/src/index.d.ts
vendored
46
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -11,11 +11,8 @@ export interface None {
|
||||||
|
|
||||||
export type MaybeNone<T> = undefined extends T ? None : never;
|
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||||
|
|
||||||
type ReactiveObject = { [key: Key]: any };
|
type GetterMap = Record<string, () => any>;
|
||||||
|
type SetterMap = Record<string, Setter<any>>;
|
||||||
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
|
|
||||||
|
|
||||||
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infers the type of the return values produced by a map of functions.
|
* Infers the type of the return values produced by a map of functions.
|
||||||
|
|
@ -58,7 +55,7 @@ export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State
|
||||||
* A payload that can be sent from the server to the client to synchronize
|
* A payload that can be sent from the server to the client to synchronize
|
||||||
* state between the two.
|
* state between the two.
|
||||||
*/
|
*/
|
||||||
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
|
export type SyncPayload<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> =
|
||||||
| { type: "init"; data: StateOfMap<Getters> }
|
| { type: "init"; data: StateOfMap<Getters> }
|
||||||
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
||||||
|
|
||||||
|
|
@ -78,17 +75,14 @@ export const config: {
|
||||||
* Whether the history of state changes since the client's last update
|
* Whether the history of state changes since the client's last update
|
||||||
* should be preserved. This is useful for values that change multiple
|
* should be preserved. This is useful for values that change multiple
|
||||||
* times per frame, where each individual change is important. Defaults
|
* times per frame, where each individual change is important. Defaults
|
||||||
* to `false` for performance reasons.
|
* to `false`.
|
||||||
*
|
*
|
||||||
* @server
|
* @server
|
||||||
*/
|
*/
|
||||||
preserveHistory: boolean;
|
preserveHistory: boolean;
|
||||||
/**
|
/**
|
||||||
* When `true`, Charm will modify sparse arrays to prevent data loss from
|
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||||
* remote event limitations. When an unsafe array is detected, numeric
|
* to address remote event argument limitations. Defaults to `true`.
|
||||||
* indices will be converted to string keys so that the array can be sent
|
|
||||||
* safely and then converted back to an array on the receiving end.
|
|
||||||
*
|
|
||||||
* This option should be disabled if your network library uses a custom
|
* This option should be disabled if your network library uses a custom
|
||||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||||
*
|
*
|
||||||
|
|
@ -97,7 +91,7 @@ export const config: {
|
||||||
fixArrays: boolean;
|
fixArrays: boolean;
|
||||||
/**
|
/**
|
||||||
* When `true`, synced state containing unsafe sparse arrays or mixed
|
* When `true`, synced state containing unsafe sparse arrays or mixed
|
||||||
* tables will emit a warning. Only checked in strict mode and if
|
* tables will throw an error. Only checked in strict mode and if
|
||||||
* `fixArrays` is enabled. Defaults to `true`.
|
* `fixArrays` is enabled. Defaults to `true`.
|
||||||
*
|
*
|
||||||
* @server
|
* @server
|
||||||
|
|
@ -118,18 +112,16 @@ export namespace client {
|
||||||
* Note that `server.addSignalsToClient` still requires some way to get the
|
* Note that `server.addSignalsToClient` still requires some way to get the
|
||||||
* state.
|
* state.
|
||||||
*
|
*
|
||||||
* @param signals A map of setter functions to sync with the server.
|
* @param setters A map of setter functions to sync with the server.
|
||||||
*/
|
*/
|
||||||
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
|
export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregisters from server state updates for the given keys. The signals
|
* Unregisters from server state updates for the given keys. The signals
|
||||||
* will retain their current values, but will no longer receive updates
|
* will retain their current values, but will no longer receive updates
|
||||||
* from the server.
|
* from the server.
|
||||||
*/
|
*/
|
||||||
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
|
export function removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void;
|
||||||
...keys: (keyof Signals)[]
|
|
||||||
): void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribes from all server state updates.
|
* Unsubscribes from all server state updates.
|
||||||
|
|
@ -142,7 +134,7 @@ export namespace client {
|
||||||
*
|
*
|
||||||
* @param payloads The state updates received from the server.
|
* @param payloads The state updates received from the server.
|
||||||
*/
|
*/
|
||||||
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
export function patch<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
|
||||||
payloads: SyncPayload<Getters, FixArrays>[],
|
payloads: SyncPayload<Getters, FixArrays>[],
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
@ -160,12 +152,9 @@ export namespace server {
|
||||||
* that `client.addSignals` still requires some way to set the state.
|
* that `client.addSignals` still requires some way to set the state.
|
||||||
*
|
*
|
||||||
* @param client The client receiving state updates.
|
* @param client The client receiving state updates.
|
||||||
* @param signals A map of getter functions to sync with the client.
|
* @param getters A map of getter functions to sync with the client.
|
||||||
*/
|
*/
|
||||||
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): void;
|
||||||
client: Player,
|
|
||||||
signals: Signals,
|
|
||||||
): void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||||
|
|
@ -181,9 +170,9 @@ export namespace server {
|
||||||
* @param client The client receiving state updates.
|
* @param client The client receiving state updates.
|
||||||
* @param keys The keys of the state to unsubscribe from.
|
* @param keys The keys of the state to unsubscribe from.
|
||||||
*/
|
*/
|
||||||
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
export function removeSignalsFromClient<Getters extends GetterMap = GetterMap>(
|
||||||
client: Player,
|
client: Player,
|
||||||
...keys: (keyof Signals)[]
|
...keys: (keyof Getters)[]
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -193,7 +182,7 @@ export namespace server {
|
||||||
*
|
*
|
||||||
* @param onSync Called when sending patches to a client.
|
* @param onSync Called when sending patches to a client.
|
||||||
*/
|
*/
|
||||||
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
|
||||||
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
|
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
|
|
@ -245,7 +234,7 @@ export namespace patch {
|
||||||
* @param statePatch The patches to apply.
|
* @param statePatch The patches to apply.
|
||||||
* @returns The new state with the patch applied.
|
* @returns The new state with the patch applied.
|
||||||
*/
|
*/
|
||||||
export function applyImmutable<State, FixArrays extends boolean = true>(
|
export function apply<State, FixArrays extends boolean = true>(
|
||||||
currentState: State,
|
currentState: State,
|
||||||
statePatch: SyncPatch<State, FixArrays>,
|
statePatch: SyncPatch<State, FixArrays>,
|
||||||
): State;
|
): State;
|
||||||
|
|
@ -287,6 +276,5 @@ export function isNone(value: unknown): value is None;
|
||||||
* @param getter A function that returns the current value of the signal.
|
* @param getter A function that returns the current value of the signal.
|
||||||
* @param setter A function that sets the value of the signal.
|
* @param setter A function that sets the value of the signal.
|
||||||
* @returns An atom that can either get or set the signal's value.
|
* @returns An atom that can either get or set the signal's value.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
|
|
||||||
*/
|
*/
|
||||||
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;
|
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;
|
||||||
|
|
|
||||||
|
|
@ -7,39 +7,35 @@ local flags = Charm.flags
|
||||||
|
|
||||||
export type None = types.None
|
export type None = types.None
|
||||||
|
|
||||||
|
--- A special symbol that denotes the absence of a value. Used to represent
|
||||||
|
--- deleted values in patches.
|
||||||
local None: None = { __none = "__none" }
|
local None: None = { __none = "__none" }
|
||||||
|
|
||||||
--[[
|
--- Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||||
Checks whether a value is `None`. If `true`, the value is scheduled to be
|
--- removed from the state when the patch is applied.
|
||||||
removed from the state when the patch is applied.
|
---
|
||||||
|
--- @param value The value to check.
|
||||||
@param value The value to check.
|
--- @return `true` if the value is `None`, otherwise `false`.
|
||||||
@return `true` if the value is `None`, otherwise `false`.
|
|
||||||
]]
|
|
||||||
local function isNone(value: any): boolean
|
local function isNone(value: any): boolean
|
||||||
return type(value) == "table" and value.__none == "__none"
|
return type(value) == "table" and value.__none == "__none"
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Converts `nil` values to the special `None` symbol. This is useful when
|
||||||
Converts `nil` values to the special `None` symbol. This is useful when
|
--- preparing data for serialization, since `nil` can represent both the absence
|
||||||
preparing data for serialization, since `nil` can represent both the absence
|
--- of a value and the removal of a value in patches.
|
||||||
of a value and the removal of a value in patches.
|
|
||||||
]]
|
|
||||||
local function nilToNone<T>(value: T?): T | None
|
local function nilToNone<T>(value: T?): T | None
|
||||||
return if value == nil then None else value
|
return if value == nil then None else value
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- JSON serialization can drop the last values of a sparse array. For example,
|
||||||
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
|
||||||
`{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
|
--- serialization of sparse arrays can be resolved by converting them to
|
||||||
serialization of sparse arrays can be resolved by converting them to
|
--- dictionaries with string keys.
|
||||||
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
|
||||||
long as the target array is not sparse. This function ensures that the diff
|
--- function produces a patch that can be correctly applied.
|
||||||
function produces a patch that can be correctly applied.
|
local function stringifySparseArray(object: { any }): { [string | number]: any }
|
||||||
]]
|
|
||||||
local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
|
|
||||||
local maxn = table.maxn(object)
|
local maxn = table.maxn(object)
|
||||||
|
|
||||||
if maxn == 0 or maxn == #object then
|
if maxn == 0 or maxn == #object then
|
||||||
|
|
@ -56,78 +52,68 @@ local function stringifySparseArray(object: { [any]: any }): { [string | number]
|
||||||
return dictionary
|
return dictionary
|
||||||
end
|
end
|
||||||
|
|
||||||
local function diff(oldState: any, newState: any): any
|
--- Compares two states and returns a patch that can be applied to the current
|
||||||
if oldState == newState then
|
--- state to produce the next state.
|
||||||
|
---
|
||||||
|
--- @param currentState The current state.
|
||||||
|
--- @param nextState The next state.
|
||||||
|
--- @return A patch that can be applied to the current state.
|
||||||
|
local function diff(currentState: any, nextState: any): any
|
||||||
|
if currentState == nextState then
|
||||||
return nil
|
return nil
|
||||||
elseif newState == nil then
|
elseif nextState == nil then
|
||||||
return None
|
return None
|
||||||
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
|
elseif type(currentState) ~= "table" or type(nextState) ~= "table" then
|
||||||
return newState
|
return nextState
|
||||||
end
|
end
|
||||||
|
|
||||||
local diffs = table.clone(newState)
|
local patches: { [any]: any } = table.clone(nextState)
|
||||||
|
|
||||||
for key, oldValue in oldState do
|
for key, previous in currentState do
|
||||||
diffs[key] = diff(oldValue, newState[key])
|
patches[key] = diff(previous, nextState[key])
|
||||||
end
|
|
||||||
|
|
||||||
if next(diffs) == nil then
|
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if config.fixArrays then
|
if config.fixArrays then
|
||||||
-- Convert sparse array patches that could be misserialized
|
-- Convert sparse array patches that could be misserialized
|
||||||
if oldState[1] ~= nil or newState[1] ~= nil then
|
if currentState[1] ~= nil or nextState[1] ~= nil then
|
||||||
diffs = stringifySparseArray(diffs)
|
patches = stringifySparseArray(patches)
|
||||||
end
|
end
|
||||||
|
|
||||||
if flags.strict and config.validatePatches then
|
if flags.strict and config.validatePatches then
|
||||||
validate(diffs)
|
validate(patches)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return diffs
|
return patches
|
||||||
end
|
end
|
||||||
|
|
||||||
local function apply(oldState: any, diffs: any): any
|
--- Applies a patch to a state and returns the resulting value.
|
||||||
if isNone(diffs) then
|
---
|
||||||
|
--- @param currentState The current state.
|
||||||
|
--- @param statePatch The patches to apply.
|
||||||
|
--- @return The new state with the patch applied.
|
||||||
|
local function apply(currentState: any, statePatch: any): any
|
||||||
|
if isNone(statePatch) then
|
||||||
return nil
|
return nil
|
||||||
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
|
elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then
|
||||||
return diffs
|
return statePatch
|
||||||
end
|
end
|
||||||
|
|
||||||
local newState = table.clone(oldState)
|
local nextState = table.clone(currentState)
|
||||||
local stateIsArray = oldState[1] ~= nil
|
local stateIsArray = currentState[1] ~= nil
|
||||||
|
|
||||||
for key, value in diffs do
|
for key, value in statePatch do
|
||||||
-- Diff-checking an array produces a sparse array, which will not be
|
-- Diff-checking an array produces a sparse array, which will not be
|
||||||
-- preserved when converted to JSON. To prevent this, we turn string
|
-- preserved when converted to JSON. To prevent this, we turn string
|
||||||
-- keys back into numeric keys.
|
-- keys back into numeric keys.
|
||||||
if stateIsArray and type(key) == "string" then
|
if stateIsArray and type(key) == "string" then
|
||||||
key = tonumber(key) or key
|
key = tonumber(key) or key
|
||||||
end
|
end
|
||||||
newState[key] = apply(newState[key], value)
|
|
||||||
|
nextState[key] = apply(nextState[key], value)
|
||||||
end
|
end
|
||||||
|
|
||||||
return newState
|
return nextState
|
||||||
end
|
|
||||||
|
|
||||||
local function applyMutable(targetState: any, diffs: any): any
|
|
||||||
if isNone(diffs) then
|
|
||||||
return nil
|
|
||||||
elseif type(targetState) ~= "table" or type(diffs) ~= "table" then
|
|
||||||
return diffs
|
|
||||||
end
|
|
||||||
|
|
||||||
local targetIsArray = targetState[1] ~= nil
|
|
||||||
|
|
||||||
for key, value in diffs do
|
|
||||||
if targetIsArray and type(key) == "string" then
|
|
||||||
key = tonumber(key) or key
|
|
||||||
end
|
|
||||||
targetState[key] = applyMutable(targetState[key], value)
|
|
||||||
end
|
|
||||||
|
|
||||||
return targetState
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -135,5 +121,4 @@ return {
|
||||||
nilToNone = nilToNone,
|
nilToNone = nilToNone,
|
||||||
diff = diff,
|
diff = diff,
|
||||||
apply = apply,
|
apply = apply,
|
||||||
applyMutable = applyMutable,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ local Charm = require("../Charm")
|
||||||
local config = require("./config")
|
local config = require("./config")
|
||||||
local patch = require("./patch")
|
local patch = require("./patch")
|
||||||
local types = require("./types")
|
local types = require("./types")
|
||||||
local utils = require("./utils")
|
|
||||||
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
|
|
@ -10,11 +9,10 @@ type SyncStatus = number
|
||||||
|
|
||||||
type ClientKeys = { [string]: SyncStatus }
|
type ClientKeys = { [string]: SyncStatus }
|
||||||
|
|
||||||
type TrackedState<T = any> = {
|
type GetterState<T = any> = {
|
||||||
identifier: unknown,
|
getter: () -> T,
|
||||||
currentValue: T,
|
currentValue: T,
|
||||||
lastSyncedValue: T,
|
lastSyncedValue: T,
|
||||||
isReactive: boolean,
|
|
||||||
dispose: () -> (),
|
dispose: () -> (),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,81 +25,50 @@ type StateUpdate = {
|
||||||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||||
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||||
|
|
||||||
local trackedStates: { [string]: TrackedState } = {}
|
local trackedGetters: { [string]: GetterState } = {}
|
||||||
local globalUpdates: { StateUpdate } = {}
|
local globalUpdates: { StateUpdate } = {}
|
||||||
local clients: { [any]: ClientKeys } = {}
|
local clients: { [any]: ClientKeys } = {}
|
||||||
|
|
||||||
local connection: RBXScriptConnection?
|
local connection: RBXScriptConnection?
|
||||||
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
|
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
||||||
|
|
||||||
--[[
|
local function startTracking<T>(key: string, getter: () -> T)
|
||||||
Manually converts a reactive proxy to its raw representation to potentially
|
if trackedGetters[key] then
|
||||||
support custom implementations of reactive proxies.
|
-- Ignore duplicates, but warn against name collisions
|
||||||
]]
|
if trackedGetters[key].getter ~= getter then
|
||||||
local function toRaw<T>(value: T): T
|
|
||||||
local proxy = value :: types.ReactiveProxy<T>
|
|
||||||
if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
|
|
||||||
proxy.__track()
|
|
||||||
return toRaw(proxy.__target)
|
|
||||||
end
|
|
||||||
return value
|
|
||||||
end
|
|
||||||
|
|
||||||
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
|
|
||||||
if trackedStates[key] then
|
|
||||||
-- Warn against name collisions with conflicting signals or proxies
|
|
||||||
if trackedStates[key].identifier ~= identifier then
|
|
||||||
warn("[Charm] Attempted to sync different states with the same key:", key)
|
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local trackedState: TrackedState<T>
|
local value = getter()
|
||||||
local initialValue = getter()
|
local trackedState = {
|
||||||
local rawInitialValue = toRaw(initialValue)
|
getter = getter,
|
||||||
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
|
currentValue = value,
|
||||||
|
lastSyncedValue = value,
|
||||||
local dispose = Charm.untracked(Charm.effect, function()
|
}
|
||||||
local newValue = getter()
|
|
||||||
local rawValue = toRaw(newValue)
|
|
||||||
local isReactive = newValue ~= rawValue
|
|
||||||
|
|
||||||
if not trackedState then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
|
trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
|
||||||
-- When preserving history, we need to keep track of previous values
|
-- When preserving history, we need to keep track of previous values
|
||||||
-- so we can create granular patches for each change.
|
-- so we can create granular patches for each change.
|
||||||
if config.preserveHistory then
|
if config.preserveHistory then
|
||||||
local prevSnapshot = snapshot
|
|
||||||
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
|
|
||||||
table.insert(globalUpdates, {
|
table.insert(globalUpdates, {
|
||||||
key = key,
|
key = key,
|
||||||
newValue = snapshot,
|
newValue = newValue,
|
||||||
oldValue = prevSnapshot,
|
oldValue = oldValue,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
trackedState.currentValue = newValue
|
||||||
trackedState.currentValue = rawValue
|
|
||||||
trackedState.isReactive = isReactive
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
trackedState = {
|
trackedGetters[key] = trackedState
|
||||||
identifier = identifier,
|
|
||||||
currentValue = rawInitialValue,
|
|
||||||
lastSyncedValue = snapshot,
|
|
||||||
isReactive = initialValue ~= rawInitialValue,
|
|
||||||
dispose = dispose,
|
|
||||||
}
|
|
||||||
|
|
||||||
trackedStates[key] = trackedState
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function stopTracking(key: string)
|
local function stopTracking(key: string)
|
||||||
local trackedState = trackedStates[key]
|
local trackedState = trackedGetters[key]
|
||||||
if trackedState then
|
if trackedState then
|
||||||
trackedState.dispose()
|
trackedState.dispose()
|
||||||
trackedStates[key] = nil
|
trackedGetters[key] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -114,7 +81,7 @@ local function purgeUntracked()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for key in trackedStates do
|
for key in trackedGetters do
|
||||||
if not active[key] then
|
if not active[key] then
|
||||||
stopTracking(key)
|
stopTracking(key)
|
||||||
end
|
end
|
||||||
|
|
@ -146,7 +113,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
||||||
for index, update in globalUpdates do
|
for index, update in globalUpdates do
|
||||||
local patchData = globalPatches[index]
|
local patchData = globalPatches[index]
|
||||||
|
|
||||||
if patchData == nil then
|
if not patchData then
|
||||||
continue
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -154,27 +121,25 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
||||||
local status = clientKeys[key]
|
local status = clientKeys[key]
|
||||||
|
|
||||||
if status == LISTENING_FOR_CHANGES then
|
if status == LISTENING_FOR_CHANGES then
|
||||||
local payload: SyncPayload = {
|
table.insert(payloads, {
|
||||||
type = "patch",
|
type = "patch",
|
||||||
data = { [key] = patchData },
|
data = { [key] = patchData },
|
||||||
}
|
})
|
||||||
table.insert(payloads, payload)
|
|
||||||
elseif status == PENDING_INITIAL_STATE then
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
local payload: SyncPayload = {
|
table.insert(payloads, {
|
||||||
type = "init",
|
type = "init",
|
||||||
data = { [key] = update.newValue },
|
data = { [key] = update.newValue },
|
||||||
}
|
})
|
||||||
table.insert(payloads, payload)
|
|
||||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- If a pending signal was never updated, we still need to send its initial
|
-- If a pending signal was never updated, we still need to send its initial
|
||||||
-- state. This can happen if a signal was registered after its last change.
|
-- state. This can happen if a signal was registered after its last change.
|
||||||
local initData = {}
|
local initData: { [string]: any } = {}
|
||||||
|
|
||||||
for key, status in clientKeys do
|
for key, status in clientKeys do
|
||||||
local trackedState = trackedStates[key]
|
local trackedState = trackedGetters[key]
|
||||||
|
|
||||||
if status == PENDING_INITIAL_STATE and trackedState then
|
if status == PENDING_INITIAL_STATE and trackedState then
|
||||||
initData[key] = trackedState.currentValue
|
initData[key] = trackedState.currentValue
|
||||||
|
|
@ -189,24 +154,20 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
||||||
return payloads
|
return payloads
|
||||||
end
|
end
|
||||||
|
|
||||||
local function diffGlobalState(): { [string]: any }?
|
local function diffGlobalState(): any
|
||||||
local currentGlobalState: { [string]: any } = {}
|
local currentGlobalState: { [string]: any } = {}
|
||||||
local previousGlobalState: { [string]: any } = {}
|
local previousGlobalState: { [string]: any } = {}
|
||||||
|
|
||||||
for key, trackedState in trackedStates do
|
for key, trackedState in trackedGetters do
|
||||||
local currentValue = if trackedState.isReactive
|
currentGlobalState[key] = trackedState.currentValue
|
||||||
then utils.deepCopy(trackedState.currentValue)
|
|
||||||
else trackedState.currentValue
|
|
||||||
|
|
||||||
currentGlobalState[key] = currentValue
|
|
||||||
previousGlobalState[key] = trackedState.lastSyncedValue
|
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||||
trackedState.lastSyncedValue = currentValue
|
trackedState.lastSyncedValue = trackedState.currentValue
|
||||||
end
|
end
|
||||||
|
|
||||||
return patch.diff(previousGlobalState, currentGlobalState)
|
return patch.diff(previousGlobalState, currentGlobalState)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
|
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
|
||||||
local clientKeys = clients[client]
|
local clientKeys = clients[client]
|
||||||
|
|
||||||
if not clientKeys then
|
if not clientKeys then
|
||||||
|
|
@ -220,9 +181,9 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
|
||||||
-- Create patches for initialized signals that changed, and add signals
|
-- Create patches for initialized signals that changed, and add signals
|
||||||
-- that are still pending initial state to the init payload.
|
-- that are still pending initial state to the init payload.
|
||||||
for key, status in clientKeys do
|
for key, status in clientKeys do
|
||||||
local trackedState = trackedStates[key]
|
local trackedState = trackedGetters[key]
|
||||||
|
|
||||||
if status == LISTENING_FOR_CHANGES and globalPatch then
|
if status == LISTENING_FOR_CHANGES then
|
||||||
patchData[key] = globalPatch[key]
|
patchData[key] = globalPatch[key]
|
||||||
elseif status == PENDING_INITIAL_STATE then
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
initData[key] = trackedState.currentValue
|
initData[key] = trackedState.currentValue
|
||||||
|
|
@ -231,97 +192,55 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
|
||||||
end
|
end
|
||||||
|
|
||||||
if next(initData) ~= nil then
|
if next(initData) ~= nil then
|
||||||
local payload: SyncPayload = {
|
table.insert(payloads, { type = "init", data = initData })
|
||||||
type = "init",
|
|
||||||
data = initData,
|
|
||||||
}
|
|
||||||
table.insert(payloads, payload)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if next(patchData) ~= nil then
|
if next(patchData) ~= nil then
|
||||||
local payload: SyncPayload = {
|
table.insert(payloads, { type = "patch", data = patchData })
|
||||||
type = "patch",
|
|
||||||
data = patchData,
|
|
||||||
}
|
|
||||||
table.insert(payloads, payload)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return payloads
|
return payloads
|
||||||
end
|
end
|
||||||
|
|
||||||
local function startInterval(callback: () -> ())
|
--- Subscribes a client to the given signals. When an update occurs, the client
|
||||||
if not game then
|
--- will receive a partial state patch to merge into their local state. May be
|
||||||
return
|
--- called multiple times to subscribe to additional keys.
|
||||||
end
|
---
|
||||||
|
--- Atoms, signals converted to atoms, and signal getter functions are allowed.
|
||||||
if connection then
|
--- Note that `client.addSignals` still requires some way to set the state.
|
||||||
connection:Disconnect()
|
---
|
||||||
end
|
--- @param client The client receiving state updates.
|
||||||
|
--- @param getters A map of getter functions to sync with the client.
|
||||||
local timestamp = os.clock()
|
local function addSignalsToClient(client: any, getters: { [string]: () -> any })
|
||||||
|
|
||||||
connection = game:GetService("RunService").Heartbeat:Connect(function()
|
|
||||||
if config.interval >= 0 and os.clock() >= timestamp + config.interval then
|
|
||||||
timestamp = os.clock()
|
|
||||||
debug.profilebegin("CharmSync")
|
|
||||||
callback()
|
|
||||||
debug.profileend()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
--[[
|
|
||||||
Subscribes a client to the given signals. When an update occurs, the client
|
|
||||||
will receive a partial state patch to merge into their local state. May be
|
|
||||||
called multiple times to subscribe to additional keys.
|
|
||||||
|
|
||||||
Atoms, signals converted to atoms, and signal getter functions are allowed.
|
|
||||||
Note that `client.addSignals` still requires some way to set the state.
|
|
||||||
|
|
||||||
@param client The client receiving state updates.
|
|
||||||
@param signals A map of getter functions to sync with the client.
|
|
||||||
]]
|
|
||||||
local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
|
|
||||||
if not clients[client] then
|
if not clients[client] then
|
||||||
clients[client] = {}
|
clients[client] = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
local clientKeys = clients[client]
|
local clientKeys = clients[client]
|
||||||
|
|
||||||
for key, signal in signals do
|
for key, getter in getters do
|
||||||
if type(signal) == "function" then
|
startTracking(key, if type(getter) == "function" then getter else getter.get)
|
||||||
trackSignal(key, signal, signal)
|
|
||||||
else
|
|
||||||
trackSignal(key, function()
|
|
||||||
return signal
|
|
||||||
end, signal)
|
|
||||||
end
|
|
||||||
|
|
||||||
if not clientKeys[key] then
|
if not clientKeys[key] then
|
||||||
-- Mark the signal as pending initial state so we send its full
|
-- Mark the getter as pending initial state so we send its full
|
||||||
-- value the next time we sync.
|
-- value the next time we sync.
|
||||||
clientKeys[key] = PENDING_INITIAL_STATE
|
clientKeys[key] = PENDING_INITIAL_STATE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||||
Unsubscribes a client from receiving all state updates. To only unsubscribe
|
--- from specific keys, use `unsubscribeClientFrom` instead.
|
||||||
from specific keys, use `unsubscribeClientFrom` instead.
|
---
|
||||||
|
--- @param client The client receiving state updates.
|
||||||
@param client The client receiving state updates.
|
|
||||||
]]
|
|
||||||
local function removeClient(client: any)
|
local function removeClient(client: any)
|
||||||
clients[client] = nil
|
clients[client] = nil
|
||||||
purgeUntracked()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Unsubscribes a client from receiving updates for the given keys.
|
||||||
Unsubscribes a client from receiving updates for the given keys.
|
---
|
||||||
|
--- @param client The client receiving state updates.
|
||||||
@param client The client receiving state updates.
|
--- @param keys The keys of the state to unsubscribe from.
|
||||||
@param keys The keys of the state to unsubscribe from.
|
|
||||||
]]
|
|
||||||
local function removeSignalsFromClient(client: any, ...: string)
|
local function removeSignalsFromClient(client: any, ...: string)
|
||||||
local clientKeys = clients[client]
|
local clientKeys = clients[client]
|
||||||
|
|
||||||
|
|
@ -336,27 +255,23 @@ local function removeSignalsFromClient(client: any, ...: string)
|
||||||
|
|
||||||
if next(clientKeys) == nil then
|
if next(clientKeys) == nil then
|
||||||
removeClient(client)
|
removeClient(client)
|
||||||
else
|
|
||||||
purgeUntracked()
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Immediately sends pending updates to all clients. Normally, updates are
|
||||||
Immediately sends pending updates to all clients. Normally, updates are
|
--- sent at the interval specified in `config.interval`, but this function
|
||||||
sent at the interval specified in `config.interval`, but this function
|
--- can be used to force an immediate sync.
|
||||||
can be used to force an immediate sync.
|
|
||||||
]]
|
|
||||||
local function flush()
|
local function flush()
|
||||||
if not syncCallback then
|
if not syncCallback then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local granular = config.preserveHistory
|
local granular = config.preserveHistory
|
||||||
local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
|
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
|
||||||
|
|
||||||
for client in clients do
|
for client in clients do
|
||||||
local payloads = if granular
|
local payloads = if granular
|
||||||
then selectFromGlobalPatchList(client, globalPatch :: { any })
|
then selectFromGlobalPatchList(client, globalPatch)
|
||||||
else selectFromGlobalPatch(client, globalPatch)
|
else selectFromGlobalPatch(client, globalPatch)
|
||||||
|
|
||||||
if payloads and payloads[1] then
|
if payloads and payloads[1] then
|
||||||
|
|
@ -368,25 +283,46 @@ local function flush()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
purgeUntracked()
|
||||||
table.clear(globalUpdates)
|
table.clear(globalUpdates)
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Uses the callback to send state updates to clients that are subscribed
|
||||||
Uses the callback to send state updates to clients that are subscribed
|
--- to state changes. Starts automatically sending updates at the interval
|
||||||
to state changes. Starts automatically sending updates at the interval
|
--- specified in `config.interval`.
|
||||||
specified in `config.interval`.
|
---
|
||||||
|
--- @param onSync Called when sending patches to a client.
|
||||||
@param onSync Called when sending patches to a client.
|
|
||||||
]]
|
|
||||||
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||||
|
if connection then
|
||||||
|
connection:Disconnect()
|
||||||
|
end
|
||||||
|
|
||||||
syncCallback = onSync
|
syncCallback = onSync
|
||||||
startInterval(flush)
|
|
||||||
|
if not game then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local timer = 0
|
||||||
|
|
||||||
|
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
|
||||||
|
if config.interval < 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
timer += deltaTime
|
||||||
|
|
||||||
|
if timer >= config.interval then
|
||||||
|
timer = 0
|
||||||
|
debug.profilebegin("CharmSync")
|
||||||
|
flush()
|
||||||
|
debug.profileend()
|
||||||
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Stops syncing state updates to clients at the automatic interval.
|
||||||
Stops syncing state updates to clients at the automatic interval.
|
--- Flushing can still be performed manually via `flush`.
|
||||||
Flushing can still be performed manually via `flush`.
|
|
||||||
]]
|
|
||||||
local function disconnect()
|
local function disconnect()
|
||||||
if connection then
|
if connection then
|
||||||
connection:Disconnect()
|
connection:Disconnect()
|
||||||
|
|
@ -394,12 +330,10 @@ local function disconnect()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Unsubscribes all clients, stops tracking all state, and disconnects
|
||||||
Unsubscribes all clients, stops tracking all state, and disconnects
|
--- from the automatic sync interval.
|
||||||
from the automatic sync interval.
|
|
||||||
]]
|
|
||||||
local function reset()
|
local function reset()
|
||||||
table.clear(trackedStates)
|
table.clear(trackedGetters)
|
||||||
table.clear(clients)
|
table.clear(clients)
|
||||||
table.clear(globalUpdates)
|
table.clear(globalUpdates)
|
||||||
disconnect()
|
disconnect()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
--[[
|
--- A payload that can be sent from the server to the client to synchronize
|
||||||
A payload that can be sent from the server to the client to synchronize
|
--- the states of the client's atoms with the server.
|
||||||
the states of the client's atoms with the server.
|
|
||||||
]]
|
|
||||||
export type SyncPayload =
|
export type SyncPayload =
|
||||||
| {
|
| {
|
||||||
type: "init",
|
type: "init",
|
||||||
|
|
@ -12,19 +10,8 @@ export type SyncPayload =
|
||||||
data: { [string]: any },
|
data: { [string]: any },
|
||||||
}
|
}
|
||||||
|
|
||||||
--[[
|
--- A special type that represents a removed value in a patch. This is used to
|
||||||
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.
|
||||||
differentiate between a value that was set to `nil` and an unchanged value.
|
|
||||||
]]
|
|
||||||
export type None = { __none: "__none" }
|
export type None = { __none: "__none" }
|
||||||
|
|
||||||
export type ReactiveProxy<T = any> = typeof(setmetatable(
|
|
||||||
{} :: {
|
|
||||||
__track: () -> (),
|
|
||||||
__target: T,
|
|
||||||
__signals: { [unknown]: () -> unknown },
|
|
||||||
},
|
|
||||||
{} :: { __index: T }
|
|
||||||
))
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,28 @@ local Charm = require("../Charm")
|
||||||
|
|
||||||
type Atom<T> = Charm.Atom<T>
|
type Atom<T> = Charm.Atom<T>
|
||||||
|
|
||||||
--[[
|
type NestedTable = {
|
||||||
Flattens a nested table into a single table with slash-separated keys.
|
[any]: NestedTable | any,
|
||||||
Useful for recursively collecting atoms returned by modules.
|
}
|
||||||
|
|
||||||
@param input A nested table.
|
type FlattenedTable = {
|
||||||
@return A flattened table with slash-separated keys.
|
[any]: any,
|
||||||
]]
|
}
|
||||||
local function flatten<Flattened>(input: { [any]: any }): Flattened
|
|
||||||
local result = {}
|
|
||||||
|
|
||||||
local function visit(node: { [any]: any }, path: string)
|
--- 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(input: NestedTable): FlattenedTable
|
||||||
|
local result: FlattenedTable = {}
|
||||||
|
|
||||||
|
local function visit(node: NestedTable, path: string)
|
||||||
for key, value in node do
|
for key, value in node do
|
||||||
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
|
local location = if path == "" then key else path .. "/" .. key
|
||||||
|
|
||||||
if type(value) == "table" then
|
if type(value) == "table" then
|
||||||
visit(value, location)
|
visit(value :: NestedTable, location)
|
||||||
else
|
else
|
||||||
result[location] = value
|
result[location] = value
|
||||||
end
|
end
|
||||||
|
|
@ -26,40 +32,23 @@ local function flatten<Flattened>(input: { [any]: any }): Flattened
|
||||||
|
|
||||||
visit(input, "")
|
visit(input, "")
|
||||||
|
|
||||||
return result :: any
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Converts a signal getter and setter into an atom. This is useful for
|
||||||
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
|
||||||
syncing client signals with the server, as the sync system requires both
|
--- the getter and setter to update state.
|
||||||
the getter and setter to update state.
|
---
|
||||||
|
--- @param getter A function that returns the current value of the signal.
|
||||||
@param getter A function that returns the current value of the signal.
|
--- @param setter A function that sets the value of the signal.
|
||||||
@param setter A function that sets the value of the signal.
|
--- @return An atom that can either get or set the signal's value.
|
||||||
@return An atom that can either get or set the signal's value.
|
|
||||||
]]
|
|
||||||
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
|
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
|
||||||
return function(...)
|
return function(...)
|
||||||
return if select("#", ...) == 0 then getter() else setter((...))
|
return if select("#", ...) == 0 then getter() else setter((...))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
|
||||||
Returns a deep copy of the given table. Skips tables with metatables to
|
|
||||||
prevent copying reactive proxies.
|
|
||||||
]]
|
|
||||||
local function deepCopy<T>(object: T): T
|
|
||||||
local copy: any = table.clone(object :: any)
|
|
||||||
for key, value in copy do
|
|
||||||
if type(value) == "table" and getmetatable(value) == nil then
|
|
||||||
copy[key] = deepCopy(value)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return copy
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
flatten = flatten,
|
flatten = flatten,
|
||||||
signalToAtom = signalToAtom,
|
signalToAtom = signalToAtom,
|
||||||
deepCopy = deepCopy,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
local function isTableUnsafe(object: { [any]: any })
|
local function isTableUnsafe(object: { [any]: any })
|
||||||
local firstKey = next(object)
|
local key = next(object)
|
||||||
|
|
||||||
if firstKey == nil then
|
if key == nil then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local keyType = type(firstKey)
|
local keyType = type(key)
|
||||||
local maxKey = 0
|
local maxKey = 0
|
||||||
|
|
||||||
-- Keys must all be the same type
|
-- Keys must all be the same type
|
||||||
for key in object do
|
for key in object do
|
||||||
if type(key) ~= keyType then
|
if type(key) ~= keyType then
|
||||||
return true
|
return true
|
||||||
elseif type(key) == "number" and key > maxKey then
|
elseif keyType == "number" and key > maxKey then
|
||||||
maxKey = key
|
maxKey = key
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -25,21 +25,25 @@ local function isTableUnsafe(object: { [any]: any })
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Throws an error if the value is a table that cannot be synced via
|
||||||
Emits a warning if the value is a table that cannot be synced via
|
--- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
||||||
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
--- with remote argument limitations, such as sparse arrays or mixed tables.
|
||||||
with remote argument limitations, such as sparse arrays or mixed tables.
|
---
|
||||||
|
--- @param value The table value.
|
||||||
@param value The table value.
|
--- @param key The key of the value.
|
||||||
]]
|
--- @error Throws an error if the value cannot be synced.
|
||||||
local function validate(value: { [any]: any })
|
local function validate(value: { [any]: any })
|
||||||
if isTableUnsafe(value) then
|
if isTableUnsafe(value) then
|
||||||
warn(
|
-- Silence warnings during testing
|
||||||
"Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
|
if game then
|
||||||
|
warn("Cannot sync table:", value)
|
||||||
|
end
|
||||||
|
error(
|
||||||
|
`Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
|
||||||
|
.. "This can be for the following reasons:\n"
|
||||||
.. "1. The table is a sparse array with non-sequential indices\n"
|
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||||
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
|
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
|
||||||
value
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/charm-sync"
|
name = "littensy/charm-sync"
|
||||||
version = "0.4.1"
|
version = "0.4.0-rc.1"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
exclude = [
|
exclude = [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"test",
|
"src/__tests__",
|
||||||
"src/index.d.ts",
|
"src/index.d.ts",
|
||||||
"package.json",
|
"package.json",
|
||||||
"Charm.luau",
|
"Charm.luau",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
Charm = "littensy/charm@^0.11.0"
|
Charm = "littensy/charm@0.11.0-rc.1"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/charm",
|
"name": "@rbxts/charm",
|
||||||
"version": "0.11.1",
|
"version": "0.11.0-rc.1",
|
||||||
"description": "Atomic state management based on alien-signals",
|
"description": "Atomic state management based on alien-signals",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "src/init.luau",
|
"main": "src/init.luau",
|
||||||
|
|
@ -11,14 +11,14 @@
|
||||||
"!wally.toml",
|
"!wally.toml",
|
||||||
"!wally.lock",
|
"!wally.lock",
|
||||||
"!default.project.json",
|
"!default.project.json",
|
||||||
"!test"
|
"!**/__tests__"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/littensy/charm.git"
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
"publish:wally": "wally publish"
|
"publish:wally": "wally publish"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
packages/charm/src/__tests__/computed.spec.luau
Normal file
106
packages/charm/src/__tests__/computed.spec.luau
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local signal = Charm.signal
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should correctly propagate changes through computed core", function()
|
||||||
|
local src, setSrc = signal(0)
|
||||||
|
local c1 = computed(function()
|
||||||
|
return src() % 2
|
||||||
|
end)
|
||||||
|
local c2 = computed(function()
|
||||||
|
return c1()
|
||||||
|
end)
|
||||||
|
local c3 = computed(function()
|
||||||
|
return c2()
|
||||||
|
end)
|
||||||
|
|
||||||
|
c3()
|
||||||
|
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
|
||||||
|
c2() -- c1 -> none, c2 -> none
|
||||||
|
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
|
||||||
|
|
||||||
|
assert(c3() == 1, "bad final value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should propagate updated source value through chained computations", function()
|
||||||
|
local src, setSrc = signal(0)
|
||||||
|
local a = computed(function()
|
||||||
|
return src()
|
||||||
|
end)
|
||||||
|
local b = computed(function()
|
||||||
|
return a() % 2
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return src()
|
||||||
|
end)
|
||||||
|
local d = computed(function()
|
||||||
|
return b() + c()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(d() == 0, "bad initial value")
|
||||||
|
setSrc(2)
|
||||||
|
assert(d() == 2, "bad updated value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should handle flags are indirectly updated during checkDirty", function()
|
||||||
|
local a, setA = signal(false)
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
b()
|
||||||
|
return 0
|
||||||
|
end)
|
||||||
|
local d = computed(function()
|
||||||
|
c()
|
||||||
|
return b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(d() == false, "bad initial value")
|
||||||
|
setA(true)
|
||||||
|
assert(d() == true, "bad updated value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not update if the signal value is reverted", function()
|
||||||
|
local times = 0
|
||||||
|
|
||||||
|
local src, setSrc = signal(0)
|
||||||
|
local c1 = computed(function()
|
||||||
|
times += 1
|
||||||
|
return src()
|
||||||
|
end)
|
||||||
|
c1()
|
||||||
|
assert(times == 1, "computed did not run")
|
||||||
|
setSrc(1)
|
||||||
|
setSrc(0)
|
||||||
|
c1()
|
||||||
|
assert(times == 1, "computed ran when it should not")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should receive previous value", function()
|
||||||
|
local calls = {}
|
||||||
|
local src, setSrc = signal(0)
|
||||||
|
local c1 = computed(function(current): number
|
||||||
|
local incoming = src()
|
||||||
|
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
|
||||||
|
end)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
table.insert(calls, c1())
|
||||||
|
end)
|
||||||
|
|
||||||
|
setSrc(2)
|
||||||
|
setSrc(3)
|
||||||
|
assert(c1() == 2, "should skip odd value")
|
||||||
|
|
||||||
|
setSrc(4)
|
||||||
|
setSrc(5)
|
||||||
|
assert(c1() == 4, "should skip odd value")
|
||||||
|
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
local effectScope = Charm.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local endBatch = Charm.endBatch
|
local endBatch = Charm.endBatch
|
||||||
local onCleanup = Charm.onCleanup
|
local onCleanup = Charm.onCleanup
|
||||||
|
local recursive = Charm.recursive
|
||||||
local setActiveSub = Charm.setActiveSub
|
local setActiveSub = Charm.setActiveSub
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local startBatch = Charm.startBatch
|
local startBatch = Charm.startBatch
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should clear subscriptions when untracked by all subscribers", function()
|
test("should clear subscriptions when untracked by all subscribers", function()
|
||||||
local bRunTimes = 0
|
local bRunTimes = 0
|
||||||
|
|
@ -239,11 +241,8 @@ test("should handle side effect with inner effects", function()
|
||||||
table.clear(order)
|
table.clear(order)
|
||||||
setB(1)
|
setB(1)
|
||||||
setA(1)
|
setA(1)
|
||||||
|
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- DEVIATION: This assertion was moved outside of the effect so that it
|
|
||||||
-- runs after the batch updates have been processed
|
|
||||||
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should handle flags are indirectly updated during checkDirty", function()
|
test("should handle flags are indirectly updated during checkDirty", function()
|
||||||
|
|
@ -271,52 +270,33 @@ test("should handle flags are indirectly updated during checkDirty", function()
|
||||||
assert(triggers == 2, "did not update indirectly")
|
assert(triggers == 2, "did not update indirectly")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
|
test("should handle effect recursion for the first execution", function()
|
||||||
local a, setA = signal(0)
|
local src1, setSrc1 = signal(0)
|
||||||
local b, setB = signal(0)
|
local src2, setSrc2 = signal(0)
|
||||||
local c, setC = signal(0)
|
|
||||||
local d = computed(function()
|
|
||||||
c()
|
|
||||||
return 0
|
|
||||||
end)
|
|
||||||
|
|
||||||
local effect3Executed = false
|
local triggers1 = 0
|
||||||
|
local triggers2 = 0
|
||||||
|
|
||||||
effect(function()
|
effect(function()
|
||||||
a()
|
triggers1 += 1
|
||||||
|
setSrc1(math.min(src1() + 1, 5))
|
||||||
end)
|
end)
|
||||||
effect(function()
|
effect(function()
|
||||||
if a() == 2 then
|
triggers2 += 1
|
||||||
error("Error in effect 2")
|
setSrc2(math.min(src2() + 1, 5))
|
||||||
end
|
src2()
|
||||||
end)
|
|
||||||
effect(function()
|
|
||||||
a()
|
|
||||||
d()
|
|
||||||
effect3Executed = true
|
|
||||||
end)
|
|
||||||
effect(function()
|
|
||||||
b()
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
setA(1)
|
assert(triggers1 == 1, `bad triggers1: {triggers1}`)
|
||||||
|
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
|
||||||
effect3Executed = false
|
|
||||||
local success, err = pcall(setA, 2)
|
|
||||||
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
|
|
||||||
|
|
||||||
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
|
|
||||||
setB(1)
|
|
||||||
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
|
|
||||||
setC(1)
|
|
||||||
assert(effect3Executed, "effect 3 should have executed after updating signal c")
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should allow finite recursion", function()
|
test("should opt in to recursion", function()
|
||||||
local src, setSrc = signal(0)
|
local src, setSrc = signal(0)
|
||||||
local order = {}
|
local order = {}
|
||||||
|
|
||||||
effect(function()
|
effect(function()
|
||||||
|
recursive()
|
||||||
table.insert(order, src())
|
table.insert(order, src())
|
||||||
setSrc(math.min(src() + 1, 5))
|
setSrc(math.min(src() + 1, 5))
|
||||||
end)
|
end)
|
||||||
|
|
@ -408,186 +388,4 @@ test("should run cleanups outside any evaluation context", function()
|
||||||
assert(calls == 0, "should not run after b update")
|
assert(calls == 0, "should not run after b update")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
|
|
||||||
local a, setA = signal(0)
|
|
||||||
local calls = {}
|
|
||||||
|
|
||||||
effect(function()
|
|
||||||
table.insert(calls, a())
|
|
||||||
return function()
|
|
||||||
setA(2)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(#calls == 1, "should have run once initially")
|
|
||||||
table.clear(calls)
|
|
||||||
|
|
||||||
setA(1)
|
|
||||||
assert(#calls == 1, "should run once after an update")
|
|
||||||
assert(calls[1] == 2, `bad call: {calls[1]}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should run the cleanup if the effect disposes itself", function()
|
|
||||||
local a, setA = signal(0)
|
|
||||||
local calls = 0
|
|
||||||
local dispose: () -> ()
|
|
||||||
|
|
||||||
dispose = effect(function()
|
|
||||||
if a() > 0 then
|
|
||||||
dispose()
|
|
||||||
return function()
|
|
||||||
calls += 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(calls == 0, "should not have called cleanup initially")
|
|
||||||
setA(1)
|
|
||||||
assert(calls == 1, "should have called cleanup once after first update")
|
|
||||||
setA(2)
|
|
||||||
assert(calls == 1, "should not call cleanup again after second update")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should not run the effect if the cleanup function disposes it", function()
|
|
||||||
local a, setA = signal(0)
|
|
||||||
local calls = 0
|
|
||||||
local dispose: () -> ()
|
|
||||||
|
|
||||||
dispose = effect(function()
|
|
||||||
a()
|
|
||||||
calls += 1
|
|
||||||
return function()
|
|
||||||
dispose()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(calls == 1, "should have run effect once initially")
|
|
||||||
setA(1)
|
|
||||||
assert(calls == 1, "should not run effect again after update")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should reset the cleanup if the effect throws", function()
|
|
||||||
local a, setA = signal(0)
|
|
||||||
local calls = 0
|
|
||||||
|
|
||||||
effect(function()
|
|
||||||
if a() == 0 then
|
|
||||||
return function()
|
|
||||||
calls += 1
|
|
||||||
end
|
|
||||||
else
|
|
||||||
error("hello")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(calls == 0, "should not have called cleanup initially")
|
|
||||||
local success, err = pcall(setA, 1)
|
|
||||||
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
|
|
||||||
assert(calls == 1, "should have called cleanup once after throwing")
|
|
||||||
setA(0)
|
|
||||||
assert(calls == 1, "should not call cleanup again after resetting value")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("recursive effects should run and cleanup in order", function()
|
|
||||||
local a, setA = signal(0)
|
|
||||||
local order: { string } = {}
|
|
||||||
|
|
||||||
effect(function()
|
|
||||||
local value = a()
|
|
||||||
table.insert(order, `+{value}`)
|
|
||||||
if value < 3 then
|
|
||||||
setA(value + 1)
|
|
||||||
end
|
|
||||||
return function()
|
|
||||||
table.insert(order, `-{value}`)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
|
|
||||||
table.clear(order)
|
|
||||||
setA(100)
|
|
||||||
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should effects dispose in order", function()
|
|
||||||
local order: { number } = {}
|
|
||||||
|
|
||||||
local src, setSrc = signal(false)
|
|
||||||
local stopEffect = effect(function()
|
|
||||||
src()
|
|
||||||
for i = 1, 3 do
|
|
||||||
effect(function()
|
|
||||||
return function()
|
|
||||||
table.insert(order, i)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
assert(#order == 0, "should not cleanup immediately")
|
|
||||||
setSrc(true)
|
|
||||||
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
|
|
||||||
table.clear(order)
|
|
||||||
stopEffect()
|
|
||||||
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
|
|
||||||
table.clear(order)
|
|
||||||
setSrc(false)
|
|
||||||
assert(#order == 0, "should not cleanup after dispose")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should handle inner effect clearing outer effect's deps on rerun", function()
|
|
||||||
local src, setSrc = signal(false)
|
|
||||||
local stopEffect: () -> ()
|
|
||||||
stopEffect = effect(function()
|
|
||||||
src()
|
|
||||||
effect(function()
|
|
||||||
return function()
|
|
||||||
stopEffect()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
effect(function() end)
|
|
||||||
end)
|
|
||||||
setSrc(true)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.skip("should handle inner effect cleanup disposing outer effect", function()
|
|
||||||
local order: { number } = {}
|
|
||||||
|
|
||||||
local src, setSrc = signal(false)
|
|
||||||
local stopEffect: () -> ()
|
|
||||||
stopEffect = effect(function()
|
|
||||||
src()
|
|
||||||
table.insert(order, 0)
|
|
||||||
for i = 1, 4 do
|
|
||||||
effect(function()
|
|
||||||
return function()
|
|
||||||
table.insert(order, i)
|
|
||||||
stopEffect()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(table.concat(order) == "0", "should not cleanup immediately")
|
|
||||||
|
|
||||||
table.clear(order)
|
|
||||||
setSrc(true)
|
|
||||||
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
|
|
||||||
|
|
||||||
table.clear(order)
|
|
||||||
setSrc(false)
|
|
||||||
assert(#order == 0, "should not cleanup after dispose")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.skip("should old inner effect dispose before outer effect re-runs", function()
|
|
||||||
local order: { string } = {}
|
|
||||||
local src, setSrc = signal(false)
|
|
||||||
effect(function()
|
|
||||||
src()
|
|
||||||
table.insert(order, "outer")
|
|
||||||
effect(function()
|
|
||||||
return function()
|
|
||||||
table.insert(order, "inner")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
table.clear(order)
|
|
||||||
setSrc(true)
|
|
||||||
assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
local effectScope = Charm.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local onCleanup = Charm.onCleanup
|
local onCleanup = Charm.onCleanup
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should not trigger after stop", function()
|
test("should not trigger after stop", function()
|
||||||
local count, setCount = signal(1)
|
local count, setCount = signal(1)
|
||||||
|
|
@ -31,8 +32,8 @@ end)
|
||||||
|
|
||||||
test("should run indirect scope cleanup", function()
|
test("should run indirect scope cleanup", function()
|
||||||
local cleanups = 0
|
local cleanups = 0
|
||||||
local cleanup: () -> ()
|
|
||||||
|
|
||||||
|
local cleanup
|
||||||
local stopScope = effectScope(function()
|
local stopScope = effectScope(function()
|
||||||
cleanup = effectScope(function()
|
cleanup = effectScope(function()
|
||||||
onCleanup(function()
|
onCleanup(function()
|
||||||
|
|
@ -50,8 +51,8 @@ end)
|
||||||
|
|
||||||
test("should run indirect effect cleanup", function()
|
test("should run indirect effect cleanup", function()
|
||||||
local cleanups = 0
|
local cleanups = 0
|
||||||
local a, setA = signal(false)
|
|
||||||
|
|
||||||
|
local a, setA = signal(false)
|
||||||
local stopScope = effectScope(function()
|
local stopScope = effectScope(function()
|
||||||
effect(function()
|
effect(function()
|
||||||
a()
|
a()
|
||||||
|
|
@ -70,26 +71,13 @@ test("should run indirect effect cleanup", function()
|
||||||
assert(cleanups == 2, "should not cleanup after update")
|
assert(cleanups == 2, "should not cleanup after update")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should dispose inner effects if created in an effect", function()
|
test("should disallow yielding", function()
|
||||||
local source, setSource = signal(1)
|
local success = pcall(function()
|
||||||
local triggers = 0
|
effectScope(function()
|
||||||
local dispose: () -> ()
|
(if task then task.wait else coroutine.yield :: never)()
|
||||||
|
|
||||||
effect(function()
|
|
||||||
dispose = effectScope(function()
|
|
||||||
effect(function()
|
|
||||||
source()
|
|
||||||
triggers += 1
|
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
assert(triggers == 1, "did not run initially")
|
|
||||||
end)
|
end)
|
||||||
|
assert(not success, "should error on yield")
|
||||||
setSource(2)
|
|
||||||
assert(triggers == 2, "did not run after update")
|
|
||||||
dispose()
|
|
||||||
setSource(3)
|
|
||||||
assert(triggers == 2, "ran after dispose")
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
66
packages/charm/src/__tests__/gc.spec.luau
Normal file
66
packages/charm/src/__tests__/gc.spec.luau
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local atom = Charm.atom
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local effectScope = Charm.effectScope
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
--- Creates a weak reference to a value.
|
||||||
|
local function weakRef<T>(value: T): { value: T? }
|
||||||
|
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Triggers garbage collection by creating a large number of tables.
|
||||||
|
local function gc()
|
||||||
|
for _ = 1, 2000 do
|
||||||
|
local _ = table.create(100, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- In alien-signals, a computed value unlinks from dependencies when its last
|
||||||
|
--- parent scope is disposed. However, calling a computed value outside of a
|
||||||
|
--- scope still links it to dependencies, which act as strong references.
|
||||||
|
--- Computed values called in this way won't GC until unlinked from a scope.
|
||||||
|
---
|
||||||
|
--- This function releases a computed value by linking it to a scope and then
|
||||||
|
--- immediately closing it, causing alien-signals to unlink the computed value
|
||||||
|
--- from its dependencies and allow it to be garbage collected.
|
||||||
|
local function unlink(getter: () -> ())
|
||||||
|
effectScope(function()
|
||||||
|
getter()
|
||||||
|
end)()
|
||||||
|
end
|
||||||
|
|
||||||
|
test("should release computed cache", function()
|
||||||
|
local srcRef = weakRef({})
|
||||||
|
local src = atom(srcRef.value)
|
||||||
|
local c = computed(function()
|
||||||
|
return src()
|
||||||
|
end)
|
||||||
|
|
||||||
|
c() -- cache src value
|
||||||
|
src(nil) -- release value
|
||||||
|
c = unlink(c) -- release computed
|
||||||
|
|
||||||
|
gc()
|
||||||
|
assert(srcRef.value == nil, "should release computed cache")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not release effect for atom", function()
|
||||||
|
local effectCalls = 0
|
||||||
|
local src = atom(0)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
effectCalls += 1
|
||||||
|
src()
|
||||||
|
end)
|
||||||
|
assert(effectCalls == 1, "should call effect once")
|
||||||
|
|
||||||
|
gc()
|
||||||
|
src(src() + 1)
|
||||||
|
assert(effectCalls == 2, "should call effect again")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local listen = Charm.listen
|
local listen = Charm.listen
|
||||||
|
local effect = Charm.effect
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should listen to selector", function()
|
test("should listen to selector", function()
|
||||||
local source, setSource = signal(0)
|
local source, setSource = signal(0)
|
||||||
|
|
@ -25,23 +27,32 @@ test("should listen to selector", function()
|
||||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should run in untracked", function()
|
test("should run in peek", function()
|
||||||
local runs = 0
|
local runs = 0
|
||||||
|
local cleanups = 0
|
||||||
local a, setA = signal(0)
|
local a, setA = signal(0)
|
||||||
local b, setB = signal(0)
|
local b, setB = signal(0)
|
||||||
|
|
||||||
listen(a, function()
|
listen(a, function()
|
||||||
runs += 1
|
runs += 1
|
||||||
b()
|
b()
|
||||||
|
effect(function()
|
||||||
|
return function()
|
||||||
|
cleanups += 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert(runs == 1, "should run once on init")
|
assert(runs == 1, "should run once on init")
|
||||||
|
assert(cleanups == 0, "should not cleanup yet")
|
||||||
|
|
||||||
setA(1)
|
setA(1)
|
||||||
assert(runs == 2, "should run once on change")
|
assert(runs == 2, "should run on a change")
|
||||||
|
assert(cleanups == 1, "should cleanup first call")
|
||||||
|
|
||||||
setB(1)
|
setB(1)
|
||||||
assert(runs == 2, "should not run on b change")
|
assert(runs == 2, "should not run on b change")
|
||||||
setA(2)
|
assert(cleanups == 1, "should not cleanup second call")
|
||||||
assert(runs == 3, "should run on a change")
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should run if first value is nil", function()
|
test("should run if first value is nil", function()
|
||||||
|
|
@ -57,6 +68,36 @@ test("should run if first value is nil", function()
|
||||||
assert(runs == 2, "should run once on change")
|
assert(runs == 2, "should run once on change")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
test("should track inner subs", function()
|
||||||
|
local order = {}
|
||||||
|
local a, setA = signal(1)
|
||||||
|
|
||||||
|
local unsub = listen(a, function()
|
||||||
|
table.insert(order, 1)
|
||||||
|
listen(a, function()
|
||||||
|
table.insert(order, 2)
|
||||||
|
end)
|
||||||
|
listen(a, function()
|
||||||
|
table.insert(order, 3)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
setA(a() + 1)
|
||||||
|
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
setA(a() + 1)
|
||||||
|
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
unsub()
|
||||||
|
setA(a() + 1)
|
||||||
|
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
test("should skip redundant updates", function()
|
test("should skip redundant updates", function()
|
||||||
local order = {}
|
local order = {}
|
||||||
local a, setA = signal(0)
|
local a, setA = signal(0)
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local mapped = Charm.mapped
|
local mapped = Charm.mapped
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should map state", function()
|
test("should map state", function()
|
||||||
local src, setSrc = signal({ 1, 2, 3 })
|
local src, setSrc = signal({ 1, 2, 3 })
|
||||||
|
|
@ -104,25 +105,4 @@ test("should map keys", function()
|
||||||
assert(state.D == 4, "should add d")
|
assert(state.D == 4, "should add d")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should remove old key if mapper returns a new key", function()
|
|
||||||
local src, setSrc = signal({ 1 })
|
|
||||||
local map = mapped(src, function(value: number, index: number)
|
|
||||||
return true, `{index}={value}`
|
|
||||||
end)
|
|
||||||
|
|
||||||
local state = map()
|
|
||||||
assert(state["1=1"], "should have mapped first element to key 1=1")
|
|
||||||
|
|
||||||
setSrc({ 2 })
|
|
||||||
state = map()
|
|
||||||
assert(state["1=1"] == nil, "should have removed old key 1=1")
|
|
||||||
assert(state["1=2"], "should have remapped element to key 1=2")
|
|
||||||
|
|
||||||
setSrc({ 3, 1 })
|
|
||||||
state = map()
|
|
||||||
assert(state["1=2"] == nil, "should have removed old key 1=2")
|
|
||||||
assert(state["1=3"], "should have remapped element to key 1=3")
|
|
||||||
assert(state["2=1"], "should have mapped second element to key 2=1")
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
local effectScope = Charm.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local observe = Charm.observe
|
local observe = Charm.observe
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should observe items", function()
|
test("should observe items", function()
|
||||||
local items, setItems = signal({ 0, 0, 0 })
|
local items, setItems = signal({ 0, 0, 0 })
|
||||||
local updates = {}
|
local updates = {}
|
||||||
local cleanups = {}
|
local cleanups = {}
|
||||||
|
|
||||||
observe(items, function(_, key: number)
|
observe(items, function(_, key)
|
||||||
local get = computed(function()
|
local get = computed(function()
|
||||||
return items()[key]
|
return items()[key]
|
||||||
end)
|
end)
|
||||||
|
|
@ -91,11 +92,11 @@ end)
|
||||||
|
|
||||||
test("should handle recursive additions and removals", function()
|
test("should handle recursive additions and removals", function()
|
||||||
local items, setItems = signal({ 1 })
|
local items, setItems = signal({ 1 })
|
||||||
local edits: { string } = {}
|
local edits = {}
|
||||||
|
|
||||||
observe(items, function(item)
|
observe(items, function(item)
|
||||||
if item < 5 then
|
if item < 5 then
|
||||||
setItems(function(state: { number })
|
setItems(function(state)
|
||||||
state = table.clone(state)
|
state = table.clone(state)
|
||||||
table.insert(state, item + 1)
|
table.insert(state, item + 1)
|
||||||
return state
|
return state
|
||||||
|
|
@ -114,10 +115,10 @@ end)
|
||||||
|
|
||||||
test("should handle self removal", function()
|
test("should handle self removal", function()
|
||||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||||
local edits: { string } = {}
|
local edits = {}
|
||||||
|
|
||||||
observe(items, function(item, index)
|
observe(items, function(item, index)
|
||||||
setItems(function(state: { number })
|
setItems(function(state)
|
||||||
state = table.clone(state)
|
state = table.clone(state)
|
||||||
state[index] = nil
|
state[index] = nil
|
||||||
return state
|
return state
|
||||||
|
|
@ -134,7 +135,7 @@ end)
|
||||||
test("should allow disposal during addition", function()
|
test("should allow disposal during addition", function()
|
||||||
local items, setItems = signal({})
|
local items, setItems = signal({})
|
||||||
local edits = {}
|
local edits = {}
|
||||||
local dispose: () -> ()
|
local dispose
|
||||||
|
|
||||||
dispose = observe(items, function(item)
|
dispose = observe(items, function(item)
|
||||||
if item == 1 then
|
if item == 1 then
|
||||||
|
|
@ -153,7 +154,7 @@ end)
|
||||||
test("should allow disposal during removal", function()
|
test("should allow disposal during removal", function()
|
||||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||||
local edits = {}
|
local edits = {}
|
||||||
local dispose: () -> ()
|
local dispose
|
||||||
|
|
||||||
dispose = observe(items, function(item)
|
dispose = observe(items, function(item)
|
||||||
table.insert(edits, `+{item}`)
|
table.insert(edits, `+{item}`)
|
||||||
|
|
@ -167,32 +168,4 @@ test("should allow disposal during removal", function()
|
||||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should not hang on error after recursive update", function()
|
|
||||||
local items, setItems = signal({})
|
|
||||||
|
|
||||||
observe(items, function()
|
|
||||||
setItems({ {} })
|
|
||||||
error("test error")
|
|
||||||
end)
|
|
||||||
|
|
||||||
local success, err = pcall(function()
|
|
||||||
setItems({ {} })
|
|
||||||
end)
|
|
||||||
assert(not success, "should error")
|
|
||||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should not hang on error after recursive init", function()
|
|
||||||
local items, setItems = signal({ {} })
|
|
||||||
|
|
||||||
local success, err = pcall(function()
|
|
||||||
observe(items, function()
|
|
||||||
setItems({ {} })
|
|
||||||
error("test error")
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assert(not success, "should error")
|
|
||||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
147
packages/charm/src/__tests__/peek.spec.luau
Normal file
147
packages/charm/src/__tests__/peek.spec.luau
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local peek = Charm.peek
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should not run detached", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
a()
|
||||||
|
peek(function()
|
||||||
|
effect(function()
|
||||||
|
return function()
|
||||||
|
spyCalls += 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 0, "should not be called yet")
|
||||||
|
setA(2)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
setA(3)
|
||||||
|
assert(spyCalls == 2, "should be called twice")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should block tracking inside effects", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(2)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
peek(function()
|
||||||
|
spyCalls += 1
|
||||||
|
return a() + b()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
|
||||||
|
setA(10)
|
||||||
|
setB(20)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should block tracking even when run inside effect run inside peek", function()
|
||||||
|
local s, setS = signal(1)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
peek(function()
|
||||||
|
effect(function()
|
||||||
|
peek(function()
|
||||||
|
spyCalls += 1
|
||||||
|
return s()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
|
||||||
|
setS(2)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not cause signal assignments to throw", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local aChangedTime, setAChangedTime = signal(0)
|
||||||
|
|
||||||
|
local dispose = effect(function()
|
||||||
|
a()
|
||||||
|
peek(function()
|
||||||
|
setAChangedTime(aChangedTime() + 1)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(2)
|
||||||
|
assert(aChangedTime() == 2, "aChangedTime should be 2")
|
||||||
|
setA(3)
|
||||||
|
assert(aChangedTime() == 3, "aChangedTime should be 3")
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should block tracking inside computed signals", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(2)
|
||||||
|
local spyCalls = 0
|
||||||
|
local c = computed(function()
|
||||||
|
return peek(function()
|
||||||
|
spyCalls += 1
|
||||||
|
return a() + b()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 0, "should not be called yet")
|
||||||
|
assert(c() == 3, "c should be 3")
|
||||||
|
setA(10)
|
||||||
|
c()
|
||||||
|
setB(20)
|
||||||
|
c()
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
assert(c() == 3, "c should be 3")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should track outside peek", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(2)
|
||||||
|
local c, setC = signal(3)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
a()
|
||||||
|
peek(c)
|
||||||
|
b()
|
||||||
|
spyCalls += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
setA(10)
|
||||||
|
assert(spyCalls == 2, "should be called twice")
|
||||||
|
setB(20)
|
||||||
|
assert(spyCalls == 3, "should be called three times")
|
||||||
|
setC(30)
|
||||||
|
assert(spyCalls == 3, "should not run on c change")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should stack peek", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
peek(function()
|
||||||
|
peek(a)
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
spyCalls += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
setA(2)
|
||||||
|
assert(spyCalls == 1, "should not be called twice")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should accept equality function", function()
|
test("should accept equality function", function()
|
||||||
local calls = {}
|
local calls = {}
|
||||||
local src, setSrc = signal(0, function(current: number, incoming: number)
|
local src, setSrc = signal(0, function(current, incoming)
|
||||||
return current == incoming or incoming % 2 == 1
|
return current == incoming or incoming % 2 == 1
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -46,22 +47,4 @@ test("should not freeze values with metatables", function()
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should setter return same value as getter even if equal", function()
|
|
||||||
local src, setSrc = signal(0, function()
|
|
||||||
return true
|
|
||||||
end)
|
|
||||||
assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should setter return latest value", function()
|
|
||||||
local src, setSrc = signal(0)
|
|
||||||
effect(function()
|
|
||||||
if src() == 1 then
|
|
||||||
setSrc(2)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`)
|
|
||||||
assert(src() == 2, `src() expected 2, got {src()}`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
43
packages/charm/src/__tests__/spec.luau
Normal file
43
packages/charm/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
suite.describe("computed", function()
|
||||||
|
require("./computed.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("effect", function()
|
||||||
|
require("./effect.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("effectScope", function()
|
||||||
|
require("./effectScope.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("gc", function()
|
||||||
|
require("./gc.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("listen", function()
|
||||||
|
require("./listen.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("mapped", function()
|
||||||
|
require("./mapped.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("observe", function()
|
||||||
|
require("./observe.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("peek", function()
|
||||||
|
require("./peek.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("signal", function()
|
||||||
|
require("./signal.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("subscribe", function()
|
||||||
|
require("./subscribe.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("topology", function()
|
||||||
|
require("./topology.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("trigger", function()
|
||||||
|
require("./trigger.spec")
|
||||||
|
end)
|
||||||
|
suite.describe("untracked", function()
|
||||||
|
require("./untracked.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local subscribe = Charm.subscribe
|
local subscribe = Charm.subscribe
|
||||||
|
local listen = Charm.listen
|
||||||
|
local effect = Charm.effect
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should subscribe to selector", function()
|
test("should subscribe to selector", function()
|
||||||
local source, setSource = signal(0)
|
local source, setSource = signal(0)
|
||||||
|
|
@ -26,27 +29,40 @@ test("should subscribe to selector", function()
|
||||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should run in untracked", function()
|
test("should run in peek", function()
|
||||||
local runs = 0
|
local runs = 0
|
||||||
|
local cleanups = 0
|
||||||
local a, setA = signal(0)
|
local a, setA = signal(0)
|
||||||
local b, setB = signal(0)
|
local b, setB = signal(0)
|
||||||
|
|
||||||
subscribe(a, function()
|
subscribe(a, function()
|
||||||
runs += 1
|
runs += 1
|
||||||
b()
|
b()
|
||||||
|
effect(function()
|
||||||
|
return function()
|
||||||
|
cleanups += 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert(runs == 0, "should not run once on init")
|
assert(runs == 0, "should not run once on init")
|
||||||
|
assert(cleanups == 0, "should not cleanup yet")
|
||||||
|
|
||||||
setA(1)
|
setA(1)
|
||||||
assert(runs == 1, "should run once on change")
|
assert(runs == 1, "should run once on change")
|
||||||
|
assert(cleanups == 0, "should not cleanup yet")
|
||||||
|
|
||||||
setB(1)
|
setB(1)
|
||||||
assert(runs == 1, "should not run on b change")
|
assert(runs == 1, "should not run on b change")
|
||||||
|
assert(cleanups == 0, "should not cleanup first call")
|
||||||
|
|
||||||
setA(2)
|
setA(2)
|
||||||
assert(runs == 2, "should run on a change")
|
assert(runs == 2, "should run on a change")
|
||||||
|
assert(cleanups == 1, "should cleanup first call")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should ensure correct execution order", function()
|
test("should ensure correct execution order", function()
|
||||||
local order: { number } = {}
|
local order = {}
|
||||||
local n1, setN1 = signal(0)
|
local n1, setN1 = signal(0)
|
||||||
local n2, setN2 = signal(0)
|
local n2, setN2 = signal(0)
|
||||||
local sum = computed(function()
|
local sum = computed(function()
|
||||||
|
|
@ -71,8 +87,35 @@ test("should ensure correct execution order", function()
|
||||||
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
test("should track inner subs", function()
|
||||||
|
local order = {}
|
||||||
|
local a, setA = signal(1)
|
||||||
|
|
||||||
|
local unsub = subscribe(a, function()
|
||||||
|
table.insert(order, 1)
|
||||||
|
listen(a, function()
|
||||||
|
table.insert(order, 2)
|
||||||
|
end)
|
||||||
|
listen(a, function()
|
||||||
|
table.insert(order, 3)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(a() + 1)
|
||||||
|
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
setA(a() + 1)
|
||||||
|
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
unsub()
|
||||||
|
setA(a() + 1)
|
||||||
|
assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
test("should skip redundant updates", function()
|
test("should skip redundant updates", function()
|
||||||
local order: { "even" | "odd" } = {}
|
local order = {}
|
||||||
local a, setA = signal(0)
|
local a, setA = signal(0)
|
||||||
|
|
||||||
subscribe(function(): "even" | "odd"
|
subscribe(function(): "even" | "odd"
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local atom = Charm.atom
|
local atom = Charm.atom
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
|
local describe = suite.describe
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
--[[
|
--[[
|
||||||
Tests adopted with thanks from preact-core implementation at
|
Tests adopted with thanks from preact-core implementation at
|
||||||
|
|
@ -32,7 +34,7 @@ local effect = Charm.effect
|
||||||
SOFTWARE
|
SOFTWARE
|
||||||
]]
|
]]
|
||||||
|
|
||||||
test.describe("graph updates", function()
|
describe("graph updates", function()
|
||||||
test("should drop A->B->A updates", function()
|
test("should drop A->B->A updates", function()
|
||||||
-- A
|
-- A
|
||||||
-- / |
|
-- / |
|
||||||
|
|
@ -439,19 +441,21 @@ test.describe("graph updates", function()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test.describe("error handling", function()
|
describe("error handling", function()
|
||||||
test("should keep graph consistent on errors during activation", function()
|
test("should keep graph consistent on errors during activation", function()
|
||||||
local a = atom(0)
|
local a = atom(0)
|
||||||
local b = computed(function(): nil
|
local b = computed(function()
|
||||||
error("fail")
|
error("fail")
|
||||||
end)
|
end)
|
||||||
local c = computed(function()
|
local c = computed(function()
|
||||||
return a()
|
return a()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
local success, result = pcall(b)
|
local success, result = pcall(function()
|
||||||
|
b()
|
||||||
|
end)
|
||||||
assert(not success, "should throw")
|
assert(not success, "should throw")
|
||||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
assert(string.match(result, "fail"), "should throw fail")
|
||||||
|
|
||||||
a(1)
|
a(1)
|
||||||
assert(c() == 1, "c should recover and return 1")
|
assert(c() == 1, "c should recover and return 1")
|
||||||
|
|
@ -459,7 +463,7 @@ test.describe("error handling", function()
|
||||||
|
|
||||||
test("should keep graph consistent on errors in computeds", function()
|
test("should keep graph consistent on errors in computeds", function()
|
||||||
local a = atom(0)
|
local a = atom(0)
|
||||||
local b = computed(function(): number
|
local b = computed(function()
|
||||||
if a() == 1 then
|
if a() == 1 then
|
||||||
error("fail")
|
error("fail")
|
||||||
end
|
end
|
||||||
|
|
@ -472,9 +476,11 @@ test.describe("error handling", function()
|
||||||
assert(c() == 0, "c should be 0")
|
assert(c() == 0, "c should be 0")
|
||||||
|
|
||||||
a(1)
|
a(1)
|
||||||
local success, result = pcall(b)
|
local success, result = pcall(function()
|
||||||
|
b()
|
||||||
|
end)
|
||||||
assert(not success, "should throw")
|
assert(not success, "should throw")
|
||||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
assert(string.match(result, "fail"), "should throw fail")
|
||||||
|
|
||||||
a(2)
|
a(2)
|
||||||
assert(c() == 2, "c should recover and return 2")
|
assert(c() == 2, "c should recover and return 2")
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
local listen = Charm.listen
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local subscribe = Charm.subscribe
|
local subscribe = Charm.subscribe
|
||||||
local trigger = Charm.trigger
|
local trigger = Charm.trigger
|
||||||
|
local test = suite.test
|
||||||
|
local beforeEach = suite.beforeEach
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
test.beforeEach(function()
|
beforeEach(function()
|
||||||
Charm.flags.frozen = false
|
Charm.flags.frozen = false
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test.afterEach(function()
|
afterEach(function()
|
||||||
Charm.flags.frozen = true
|
Charm.flags.frozen = true
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -67,22 +69,6 @@ test("should trigger effect once", function()
|
||||||
assert(triggers == 2, "effect should run twice")
|
assert(triggers == 2, "effect should run twice")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should not notify the trigger function sub", function()
|
|
||||||
local src1 = signal({} :: { number })
|
|
||||||
local src2 = computed(function()
|
|
||||||
return src1()
|
|
||||||
end)
|
|
||||||
|
|
||||||
effect(function()
|
|
||||||
src1()
|
|
||||||
src2()
|
|
||||||
end)
|
|
||||||
trigger(function()
|
|
||||||
src1()
|
|
||||||
src2()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("should trigger updates for subscribe", function()
|
test("should trigger updates for subscribe", function()
|
||||||
local src = signal({} :: { number })
|
local src = signal({} :: { number })
|
||||||
local triggers = 0
|
local triggers = 0
|
||||||
|
|
@ -91,21 +77,9 @@ test("should trigger updates for subscribe", function()
|
||||||
triggers += 1
|
triggers += 1
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
table.insert(src(), 1)
|
||||||
trigger(src)
|
trigger(src)
|
||||||
assert(triggers == 1, "subscribe callback should run after trigger")
|
assert(triggers == 1, "subscribe callback should run after trigger")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should trigger updates for listen", function()
|
|
||||||
local src = signal({} :: { number })
|
|
||||||
local triggers = 0
|
|
||||||
|
|
||||||
listen(src, function()
|
|
||||||
triggers += 1
|
|
||||||
end)
|
|
||||||
assert(triggers == 1, "listen callback should run once initially")
|
|
||||||
|
|
||||||
trigger(src)
|
|
||||||
assert(triggers == 2, "listen callback should run after trigger")
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
local Charm = require("../src")
|
local Charm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
local effectScope = Charm.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local untracked = Charm.untracked
|
local untracked = Charm.untracked
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("should run detached from effect", function()
|
test("should run detached from effect", function()
|
||||||
local a, setA = signal(1)
|
local a, setA = signal(1)
|
||||||
|
|
@ -38,7 +39,7 @@ end)
|
||||||
|
|
||||||
test("should run detached from effect scope", function()
|
test("should run detached from effect scope", function()
|
||||||
local effects = 0
|
local effects = 0
|
||||||
local stopEffect: () -> ()
|
local stopEffect
|
||||||
|
|
||||||
local stopScope = effectScope(function()
|
local stopScope = effectScope(function()
|
||||||
untracked(function()
|
untracked(function()
|
||||||
143
packages/charm/src/index.d.ts
vendored
143
packages/charm/src/index.d.ts
vendored
|
|
@ -1,15 +1,15 @@
|
||||||
type Key = string | number | symbol;
|
type Key = string | number | symbol;
|
||||||
|
|
||||||
type Cleanup = () => void;
|
type Cleanup = () => void;
|
||||||
|
|
||||||
export enum ReactiveFlags {
|
export enum ReactiveFlags {
|
||||||
None = 0b000000,
|
None = 0b0000000,
|
||||||
Mutable = 0b000001,
|
Mutable = 0b0000001,
|
||||||
Watching = 0b000010,
|
Watching = 0b0000010,
|
||||||
RecursedCheck = 0b000100,
|
RecursedCheck = 0b0000100,
|
||||||
Recursed = 0b001000,
|
Recursed = 0b0001000,
|
||||||
Dirty = 0b010000,
|
Dirty = 0b0010000,
|
||||||
Pending = 0b100000,
|
Pending = 0b0100000,
|
||||||
|
Peeking = 0b1000000,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactiveNode {
|
export interface ReactiveNode {
|
||||||
|
|
@ -31,15 +31,11 @@ export interface Link {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Atom<T> {
|
export interface Atom<T> {
|
||||||
(update: Update<T>): T;
|
(newValue: T | ((currentValue: T) => T)): T;
|
||||||
(): T;
|
(): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Getter<T> = () => T;
|
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
|
||||||
|
|
||||||
export type Setter<T> = (update: Update<T>) => T;
|
|
||||||
|
|
||||||
export type Update<T> = ((currentValue: T) => T) | T;
|
|
||||||
|
|
||||||
export type Equals<T> = (current: T, incoming: T) => boolean;
|
export type Equals<T> = (current: T, incoming: T) => boolean;
|
||||||
|
|
||||||
|
|
@ -50,12 +46,12 @@ export const flags: {
|
||||||
/**
|
/**
|
||||||
* Enforces synchronous, non-yielding behavior in signals and effects.
|
* Enforces synchronous, non-yielding behavior in signals and effects.
|
||||||
* Also enables state validation in Charm Sync to catch sync errors early.
|
* Also enables state validation in Charm Sync to catch sync errors early.
|
||||||
* Enabled by default in Roblox Studio.
|
* Enabled if the optimization level is below 2, which is true in studio.
|
||||||
*/
|
*/
|
||||||
strict: boolean;
|
strict: boolean;
|
||||||
/**
|
/**
|
||||||
* Enforces immutability of tables by deep-freezing table values. Enabled
|
* Enforces immutability of tables by deep-freezing table values. Enabled
|
||||||
* by default in Roblox Studio.
|
* if the optimization level is below 2, which is true in studio.
|
||||||
*/
|
*/
|
||||||
frozen: boolean;
|
frozen: boolean;
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,8 +71,13 @@ export const flags: {
|
||||||
* @returns A tuple containing the getter and setter functions for the signal.
|
* @returns A tuple containing the getter and setter functions for the signal.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||||
*/
|
*/
|
||||||
export function signal<T>(initialValue: T, equals?: Equals<T>): LuaTuple<[getter: Getter<T>, setter: Setter<T>]>;
|
export function signal<T>(
|
||||||
export function signal<T>(): LuaTuple<[getter: Getter<T | undefined>, setter: Setter<T | undefined>]>;
|
initialValue: T,
|
||||||
|
equals?: Equals<T>,
|
||||||
|
): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>;
|
||||||
|
export function signal<T>(): LuaTuple<
|
||||||
|
[getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined]
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a reactive atom that acts as both a signal getter and setter.
|
* Creates a reactive atom that acts as both a signal getter and setter.
|
||||||
|
|
@ -98,7 +99,7 @@ export function atom<T>(): Atom<T | undefined>;
|
||||||
* @param getter A function that produces the next value.
|
* @param getter A function that produces the next value.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||||
*/
|
*/
|
||||||
export function computed<T>(getter: (previousValue?: T) => T): Getter<T>;
|
export function computed<T>(getter: (previousValue?: T) => T): () => T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an effect that runs a callback in response to signal state changes.
|
* Creates an effect that runs a callback in response to signal state changes.
|
||||||
|
|
@ -149,12 +150,10 @@ export function trigger(fn: () => void): void;
|
||||||
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
|
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the function without subscribing to signal updates and prevents the
|
* Runs the function without subscribing to signal updates or capturing inner
|
||||||
* current effect or scope from tracking inner effects created within the
|
* effects in the parent effect or scope.
|
||||||
* function.
|
|
||||||
*
|
*
|
||||||
* To avoid tracking signals while still allowing inner effects to be tracked
|
* For an alternative that still cleans up inner effects, use `peek` instead.
|
||||||
* by parent effects/scopes, use `effectScope()` instead.
|
|
||||||
*
|
*
|
||||||
* @param fn A function that may read signals or create effects.
|
* @param fn A function that may read signals or create effects.
|
||||||
* @param args Arguments to pass to the function.
|
* @param args Arguments to pass to the function.
|
||||||
|
|
@ -163,6 +162,19 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
|
||||||
*/
|
*/
|
||||||
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the function without subscribing to signal updates, but still captures
|
||||||
|
* inner effects so that they dispose with the parent effect.
|
||||||
|
*
|
||||||
|
* To avoid capturing inner effects, use `untracked` instead.
|
||||||
|
*
|
||||||
|
* @param fn A function that may read signals.
|
||||||
|
* @param args Arguments to pass to the function.
|
||||||
|
* @returns The return value of the function.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
|
||||||
|
*/
|
||||||
|
export function peek<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combines multiple updates into a single "commit" that runs effects and
|
* Combines multiple updates into a single "commit" that runs effects and
|
||||||
* computed signals after the provided function finishes running.
|
* computed signals after the provided function finishes running.
|
||||||
|
|
@ -179,51 +191,54 @@ export function batch<Args extends any[], Result>(fn: (...args: Args) => Result,
|
||||||
* when the value returned by the getter changes. The callback receives the new
|
* when the value returned by the getter changes. The callback receives the new
|
||||||
* value and the previous value as arguments.
|
* value and the previous value as arguments.
|
||||||
*
|
*
|
||||||
|
* Effects created within the callback are cleaned up when the listener runs
|
||||||
|
* or the listener is disposed.
|
||||||
|
*
|
||||||
* @param getter A function that returns the value to watch.
|
* @param getter A function that returns the value to watch.
|
||||||
* @param callback The function to run when the value changes.
|
* @param fn The function to run when the value changes.
|
||||||
* @returns A function for disposing the effect.
|
* @returns A function for disposing the effect.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||||
*/
|
*/
|
||||||
export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
|
export function listen<T>(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an effect that only runs the callback when the value returned by the
|
* Creates an effect that only runs the callback when the value returned by the
|
||||||
* getter changes. The callback receives the new value and the previous value
|
* getter changes. The callback receives the new value and the previous value
|
||||||
* as arguments.
|
* as arguments.
|
||||||
*
|
*
|
||||||
|
* Effects created within the callback are cleaned up when the listener runs
|
||||||
|
* or the listener is disposed.
|
||||||
|
*
|
||||||
* @param getter A function that returns the value to watch.
|
* @param getter A function that returns the value to watch.
|
||||||
* @param callback The function to run when the value changes.
|
* @param fn The function to run when the value changes.
|
||||||
* @returns A function for disposing the effect.
|
* @returns A function for disposing the effect.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||||
*/
|
*/
|
||||||
export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
|
export function subscribe<T>(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new read-only signal that is computed by mapping over the entries
|
* Creates a new read-only signal that is computed by mapping over the entries
|
||||||
* of the source table. If the key is omitted from the transform function's
|
* of an existing atom. If the key is omitted from the mapper's return value,
|
||||||
* return value, the original key is preserved.
|
* the original key is preserved.
|
||||||
*
|
*
|
||||||
* This function is optimized to minimize unnecessary updates by only calling
|
* @param getter Returns the table to map over.
|
||||||
* the transform function for entries that have changed since the last run.
|
* @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
|
||||||
*
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
|
||||||
* @param getter A signal or function that returns a table
|
|
||||||
* @param transform Function that maps each entry from the source to a new entry in the mapped table
|
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
|
|
||||||
*/
|
*/
|
||||||
export function mapped<VI, KI, VO, KO = KI>(
|
export function mapped<VI, KI, VO, KO = KI>(
|
||||||
getter: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
|
getter: () => Map<KI, VI> | ReadonlyMap<KI, VI>,
|
||||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||||
): Getter<ReadonlyMap<KO, VO>>;
|
): () => ReadonlyMap<KO, VO>;
|
||||||
// Overload for arrays
|
// Overload for arrays
|
||||||
export function mapped<VI, VO, K extends Key = number>(
|
export function mapped<VI, VO, K extends Key = number>(
|
||||||
getter: Getter<readonly VI[]>,
|
getter: () => readonly VI[],
|
||||||
transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||||
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
|
): () => K extends number ? readonly VO[] : { readonly [P in K]: VO };
|
||||||
// Overload for objects
|
// Overload for objects
|
||||||
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||||
getter: Getter<{ readonly [K in KI]: VI }>,
|
getter: () => { readonly [K in KI]: VI },
|
||||||
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||||
): Getter<{ readonly [K in KO]: VO }>;
|
): () => { readonly [K in KO]: VO };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls the callback function for each unique key in the table returned by the
|
* Calls the callback function for each unique key in the table returned by the
|
||||||
|
|
@ -237,44 +252,38 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||||
* from the table or when the callback is disposed.
|
* from the table or when the callback is disposed.
|
||||||
*
|
*
|
||||||
* @param getter A function that returns the table to observe.
|
* @param getter A function that returns the table to observe.
|
||||||
* @param callback A function that is called for each key in the table.
|
* @param fn A function that is called for each key in the table.
|
||||||
* @returns A function for disposing the observer.
|
* @returns A function for disposing the observer.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||||
*/
|
*/
|
||||||
export function observe<V, K = number>(
|
export function observe<V, K = number>(
|
||||||
getter: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
|
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
|
||||||
callback: (value: V, key: K) => Cleanup | void,
|
fn: (value: V, key: K) => Cleanup | void,
|
||||||
): Cleanup;
|
): Cleanup;
|
||||||
// Overload for objects
|
// Overload for objects
|
||||||
export function observe<V, K extends Key>(
|
export function observe<V, K extends Key>(
|
||||||
getter: Getter<{ readonly [K in Key]: V }>,
|
getter: () => { readonly [K in Key]: V },
|
||||||
callback: (value: V, key: K) => Cleanup | void,
|
fn: (value: V, key: K) => Cleanup | void,
|
||||||
): Cleanup;
|
): Cleanup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the currently active subscriber (effect or computed node).
|
* Disables recursive checks for the current effect or computed signal.
|
||||||
|
* Useful for effects that intentionally update signals they depend on.
|
||||||
*
|
*
|
||||||
* @return Current active subscriber or `nil`
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* effect(() => {
|
||||||
|
* recursive();
|
||||||
|
* setCount(getCount() + 1);
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
|
export function recursive(): void;
|
||||||
|
|
||||||
export function getActiveSub(): ReactiveNode | undefined;
|
export function getActiveSub(): ReactiveNode | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the currently active subscriber (effect or computed node) and returns
|
|
||||||
* the previous subscriber.
|
|
||||||
*
|
|
||||||
* @param sub New subscriber to set as active
|
|
||||||
* @return Previous active subscriber
|
|
||||||
*/
|
|
||||||
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a batch operation, incrementing the batch depth counter. Effects
|
|
||||||
* will not run until the outermost batch ends.
|
|
||||||
*/
|
|
||||||
export function startBatch(): void;
|
export function startBatch(): void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Ends a batch operation, decrementing the batch depth counter. If this is
|
|
||||||
* the outermost batch, queued effects are flushed and run.
|
|
||||||
*/
|
|
||||||
export function endBatch(): void;
|
export function endBatch(): void;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,28 @@
|
||||||
--[[
|
--[[
|
||||||
Reactive system adapted from alien-signals at
|
Reactive system adapted from alien-signals at
|
||||||
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
|
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024-present Johnson Chu
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
export type ReactiveNode = {
|
export type ReactiveNode = {
|
||||||
|
|
@ -28,15 +50,16 @@ type Stack<T> = {
|
||||||
|
|
||||||
export type ReactiveFlags = number
|
export type ReactiveFlags = number
|
||||||
|
|
||||||
local NONE = 0b000000
|
local NONE = 0b0000000
|
||||||
local MUTABLE = 0b000001
|
local MUTABLE = 0b0000001
|
||||||
local WATCHING = 0b000010
|
local WATCHING = 0b0000010
|
||||||
local RECURSED_CHECK = 0b000100
|
local RECURSED_CHECK = 0b0000100
|
||||||
local RECURSED = 0b001000
|
local RECURSED = 0b0001000
|
||||||
local DIRTY = 0b010000
|
local DIRTY = 0b0010000
|
||||||
local PENDING = 0b100000
|
local PENDING = 0b0100000
|
||||||
|
local PEEKING = 0b1000000
|
||||||
|
|
||||||
local ReactiveFlags = table.freeze({
|
local ReactiveFlags = {
|
||||||
None = NONE,
|
None = NONE,
|
||||||
Mutable = MUTABLE,
|
Mutable = MUTABLE,
|
||||||
Watching = WATCHING,
|
Watching = WATCHING,
|
||||||
|
|
@ -44,160 +67,161 @@ local ReactiveFlags = table.freeze({
|
||||||
Recursed = RECURSED,
|
Recursed = RECURSED,
|
||||||
Dirty = DIRTY,
|
Dirty = DIRTY,
|
||||||
Pending = PENDING,
|
Pending = PENDING,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function createReactiveSystem(props: {
|
||||||
|
update: (sub: ReactiveNode) -> boolean,
|
||||||
|
notify: (sub: ReactiveNode) -> (),
|
||||||
|
unwatched: (sub: ReactiveNode) -> (),
|
||||||
})
|
})
|
||||||
|
local update = props.update
|
||||||
|
local notify = props.notify
|
||||||
|
local unwatched = props.unwatched
|
||||||
|
|
||||||
local update: (sub: ReactiveNode) -> boolean
|
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||||
local notify: (sub: ReactiveNode) -> ()
|
local link = sub.depsTail
|
||||||
local unwatched: (sub: ReactiveNode) -> ()
|
while link do
|
||||||
|
if link == checkLink then
|
||||||
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
return true
|
||||||
local link = sub.depsTail
|
end
|
||||||
while link do
|
link = link.prevDep
|
||||||
if link == checkLink then
|
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
link = link.prevDep
|
return false
|
||||||
end
|
end
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
|
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): ()
|
||||||
local prevDep = sub.depsTail
|
-- DEVIATION: Do not link signals to peeking subscribers
|
||||||
if prevDep and prevDep.dep == dep then
|
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
|
||||||
return
|
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
|
||||||
end
|
local prevDep = sub.depsTail
|
||||||
return nextDep
|
if prevDep and prevDep.dep == dep then
|
||||||
end
|
return
|
||||||
|
end
|
||||||
local function propagate(link: Link)
|
local nextDep = if prevDep then prevDep.nextDep else sub.deps
|
||||||
repeat
|
if nextDep and nextDep.dep == dep then
|
||||||
local sub = link.sub
|
nextDep.version = version
|
||||||
local flags = sub.flags
|
sub.depsTail = nextDep
|
||||||
|
return
|
||||||
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
|
end
|
||||||
sub.flags = bit32.bor(flags, PENDING)
|
local prevSub = dep.subsTail
|
||||||
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
|
if prevSub and prevSub.version == version and prevSub.sub == sub then
|
||||||
flags = NONE
|
return
|
||||||
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
|
end
|
||||||
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
|
local newLink: Link = {
|
||||||
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
|
version = version,
|
||||||
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
|
dep = dep,
|
||||||
flags = bit32.band(flags, MUTABLE)
|
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
|
else
|
||||||
flags = NONE
|
sub.deps = newLink
|
||||||
end
|
end
|
||||||
|
if prevSub then
|
||||||
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
prevSub.nextSub = newLink
|
||||||
notify(sub)
|
else
|
||||||
|
dep.subs = newLink
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if bit32.btest(flags, MUTABLE) then
|
local function unlink(link: Link, sub: ReactiveNode?): Link?
|
||||||
local subSubs = sub.subs
|
local sub = sub or link.sub
|
||||||
if subSubs then
|
local dep = link.dep
|
||||||
propagate(subSubs)
|
local prevDep = link.prevDep
|
||||||
|
local nextDep = link.nextDep
|
||||||
|
local nextSub = link.nextSub
|
||||||
|
local prevSub = link.prevSub
|
||||||
|
if nextDep then
|
||||||
|
nextDep.prevDep = prevDep
|
||||||
|
else
|
||||||
|
sub.depsTail = prevDep
|
||||||
|
end
|
||||||
|
if prevDep then
|
||||||
|
prevDep.nextDep = nextDep
|
||||||
|
else
|
||||||
|
sub.deps = nextDep
|
||||||
|
end
|
||||||
|
if nextSub then
|
||||||
|
nextSub.prevSub = prevSub
|
||||||
|
else
|
||||||
|
dep.subsTail = prevSub
|
||||||
|
end
|
||||||
|
if prevSub then
|
||||||
|
prevSub.nextSub = nextSub
|
||||||
|
else
|
||||||
|
dep.subs = nextSub
|
||||||
|
if not nextSub then
|
||||||
|
unwatched(dep)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
return nextDep
|
||||||
|
end
|
||||||
|
|
||||||
link = link.nextSub :: Link
|
local function propagate(link: Link): ()
|
||||||
until not link
|
repeat
|
||||||
end
|
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
|
||||||
|
-- selene: allow(if_same_then_else)
|
||||||
|
flags = NONE
|
||||||
|
end
|
||||||
|
|
||||||
local function shallowPropagate(link: Link)
|
|
||||||
repeat
|
|
||||||
local sub = link.sub
|
|
||||||
local flags = sub.flags
|
|
||||||
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
|
|
||||||
sub.flags = bit32.bor(flags, DIRTY)
|
|
||||||
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
||||||
notify(sub)
|
notify(sub)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
link = link.nextSub :: Link
|
|
||||||
until not link
|
|
||||||
end
|
|
||||||
|
|
||||||
local function checkDirty(link: Link, sub: ReactiveNode): boolean
|
if bit32.btest(flags, MUTABLE) then
|
||||||
repeat
|
local subSubs = sub.subs
|
||||||
local dep = link.dep
|
if subSubs then
|
||||||
local depFlags = dep.flags
|
propagate(subSubs)
|
||||||
|
|
||||||
if bit32.btest(sub.flags, DIRTY) then
|
|
||||||
return true
|
|
||||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
|
|
||||||
if update(dep) then
|
|
||||||
local subs = dep.subs :: Link
|
|
||||||
if subs.nextSub then
|
|
||||||
shallowPropagate(subs)
|
|
||||||
end
|
end
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
|
|
||||||
if checkDirty(dep.deps :: Link, dep) then
|
link = link.nextSub :: Link
|
||||||
|
until not link
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shallowPropagate(link: Link): ()
|
||||||
|
repeat
|
||||||
|
local sub = link.sub
|
||||||
|
local flags = sub.flags
|
||||||
|
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
|
||||||
|
sub.flags = bit32.bor(flags, DIRTY)
|
||||||
|
if bit32.btest(flags, WATCHING) then
|
||||||
|
notify(sub)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
link = link.nextSub :: Link
|
||||||
|
until not link
|
||||||
|
end
|
||||||
|
|
||||||
|
local function checkDirty(link: Link, sub: ReactiveNode): boolean
|
||||||
|
repeat
|
||||||
|
local dep = link.dep
|
||||||
|
local depFlags = dep.flags
|
||||||
|
|
||||||
|
if bit32.btest(sub.flags, DIRTY) then
|
||||||
|
return true
|
||||||
|
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
|
||||||
if update(dep) then
|
if update(dep) then
|
||||||
local subs = dep.subs :: Link
|
local subs = dep.subs :: Link
|
||||||
if subs.nextSub then
|
if subs.nextSub then
|
||||||
|
|
@ -205,33 +229,36 @@ local function checkDirty(link: Link, sub: ReactiveNode): boolean
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
else
|
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
|
||||||
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
|
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
|
end
|
||||||
end
|
|
||||||
|
|
||||||
link = link.nextDep :: Link
|
link = link.nextDep :: Link
|
||||||
until not link
|
until not link
|
||||||
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createReactiveSystem(
|
return {
|
||||||
updateFn: (sub: ReactiveNode) -> boolean,
|
link = link,
|
||||||
notifyFn: (sub: ReactiveNode) -> (),
|
unlink = unlink,
|
||||||
unwatchedFn: (sub: ReactiveNode) -> ()
|
propagate = propagate,
|
||||||
)
|
checkDirty = checkDirty,
|
||||||
update = updateFn
|
shallowPropagate = shallowPropagate,
|
||||||
notify = notifyFn
|
}
|
||||||
unwatched = unwatchedFn
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ReactiveFlags = ReactiveFlags,
|
ReactiveFlags = ReactiveFlags,
|
||||||
link = link,
|
|
||||||
unlink = unlink,
|
|
||||||
propagate = propagate,
|
|
||||||
checkDirty = checkDirty,
|
|
||||||
shallowPropagate = shallowPropagate,
|
|
||||||
createReactiveSystem = createReactiveSystem,
|
createReactiveSystem = createReactiveSystem,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
--!optimize 1
|
|
||||||
|
|
||||||
local Charm = require("../src")
|
|
||||||
local test = require("../../../tests/test")
|
|
||||||
|
|
||||||
test("should disallow yielding", function()
|
|
||||||
local success = pcall(function()
|
|
||||||
Charm.effectScope(function()
|
|
||||||
coroutine.yield()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assert(not success, "should error on yield")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.describe("traceback", function()
|
|
||||||
local DEFAULT_ERROR = "Test error"
|
|
||||||
|
|
||||||
local function fail(message: string?)
|
|
||||||
error(message or DEFAULT_ERROR, 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function indirectError(message: string?)
|
|
||||||
fail(message)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function assertStackTrace(err: unknown, expectation: string?)
|
|
||||||
expectation = expectation or DEFAULT_ERROR
|
|
||||||
assert(type(err) == "string", "Expected error to be a string")
|
|
||||||
assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`)
|
|
||||||
assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`)
|
|
||||||
assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`)
|
|
||||||
end
|
|
||||||
|
|
||||||
test("signal update", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local _, setSrc = Charm.signal(0)
|
|
||||||
setSrc(function()
|
|
||||||
indirectError()
|
|
||||||
return 1
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("atom update", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src = Charm.atom(0)
|
|
||||||
src(function()
|
|
||||||
indirectError()
|
|
||||||
return 1
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("computed", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src = Charm.computed(function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
src()
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("effect init", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
Charm.effect(function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("effect run", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src, setSrc = Charm.signal(0)
|
|
||||||
Charm.effect(function()
|
|
||||||
if src() == 1 then
|
|
||||||
indirectError()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
setSrc(1)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("effect cleanups", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local dispose = Charm.effect(function()
|
|
||||||
Charm.onCleanup(function()
|
|
||||||
indirectError("onCleanup")
|
|
||||||
end)
|
|
||||||
return function()
|
|
||||||
indirectError("returned cleanup")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
dispose()
|
|
||||||
end)
|
|
||||||
assertStackTrace(err, "onCleanup")
|
|
||||||
assertStackTrace(err, "returned cleanup")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("trigger", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
Charm.trigger(function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("untracked", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
Charm.effectScope(function()
|
|
||||||
Charm.untracked(function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("batch", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
Charm.batch(function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("subscribe getter", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
Charm.subscribe(function()
|
|
||||||
indirectError()
|
|
||||||
end, function() end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("subscribe callback", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src, setSrc = Charm.signal(0)
|
|
||||||
Charm.subscribe(src, function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
setSrc(1)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("listen getter", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
Charm.listen(function()
|
|
||||||
indirectError()
|
|
||||||
end, function() end)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("listen callback", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src, setSrc = Charm.signal(0)
|
|
||||||
Charm.listen(src, function()
|
|
||||||
indirectError()
|
|
||||||
end)
|
|
||||||
setSrc(1)
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("mapped getter", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src = Charm.mapped(function()
|
|
||||||
indirectError()
|
|
||||||
return { 1 }
|
|
||||||
end, function()
|
|
||||||
return 1
|
|
||||||
end)
|
|
||||||
src()
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test("mapped transform", function()
|
|
||||||
local _, err = pcall(function()
|
|
||||||
local src = Charm.mapped(function()
|
|
||||||
return { 1 }
|
|
||||||
end, function()
|
|
||||||
indirectError()
|
|
||||||
return 1
|
|
||||||
end)
|
|
||||||
src()
|
|
||||||
end)
|
|
||||||
assertStackTrace(err)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/charm"
|
name = "littensy/charm"
|
||||||
version = "0.11.1"
|
version = "0.11.0-rc.1"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
|
exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
if not script then
|
|
||||||
return require("../charm/src")
|
|
||||||
end
|
|
||||||
|
|
||||||
return require(script.Parent.Parent.charm.src)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"name": "deep-charm",
|
|
||||||
"tree": {
|
|
||||||
"$path": "src"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"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
53
packages/deep-charm/src/index.d.ts
vendored
|
|
@ -1,53 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
local test = require("../../tests/test")
|
|
||||||
|
|
||||||
test.describe("reactive", function()
|
|
||||||
require(`@self/reactive.test`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.describe("gc", function()
|
|
||||||
require(`@self/gc.test`)
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[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 +1,5 @@
|
||||||
return require(script.Parent.Parent.charm.src)
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
||||||
|
return Charm
|
||||||
|
|
|
||||||
|
|
@ -1 +1,12 @@
|
||||||
|
type CoreReactBinding<T> = {
|
||||||
|
getValue: (self: CoreReactBinding<T>) -> T,
|
||||||
|
_source: string?,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactBindingMap = {
|
||||||
|
map: <T, U>(self: CoreReactBinding<T> & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding<U>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Binding<T> = CoreReactBinding<T> & ReactBindingMap
|
||||||
|
|
||||||
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)
|
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/react-charm",
|
"name": "@rbxts/react-charm",
|
||||||
"version": "0.4.1",
|
"version": "0.4.0-rc.1",
|
||||||
"description": "React bindings for Charm state management",
|
"description": "React bindings for Charm state management",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "src/init.luau",
|
"main": "src/init.luau",
|
||||||
|
|
@ -11,20 +11,20 @@
|
||||||
"!wally.toml",
|
"!wally.toml",
|
||||||
"!wally.lock",
|
"!wally.lock",
|
||||||
"!default.project.json",
|
"!default.project.json",
|
||||||
"!test"
|
"!**/__tests__"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/littensy/charm.git"
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
"publish:wally": "wally publish"
|
"publish:wally": "wally publish"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rbxts/charm": "workspace:^"
|
"@rbxts/charm": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rbxts/react": ">=17.0.0"
|
"@rbxts/react": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
local React = require("../../React")
|
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||||
local ReactRoblox = require("../../ReactRoblox")
|
|
||||||
|
local React = require("../../../React")
|
||||||
|
local ReactRoblox = require("../../../ReactRoblox")
|
||||||
|
|
||||||
type RenderedHook<Props, Result> = {
|
type RenderedHook<Props, Result> = {
|
||||||
result: Result,
|
result: Result,
|
||||||
|
|
@ -13,13 +15,12 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
|
||||||
|
|
||||||
local function Component(props: Props)
|
local function Component(props: Props)
|
||||||
hook.result = callback(props)
|
hook.result = callback(props)
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function hook.rerender(newProps: Props?)
|
function hook.rerender(newProps: Props?)
|
||||||
props = newProps or props
|
props = newProps or props
|
||||||
ReactRoblox.act(function()
|
ReactRoblox.act(function()
|
||||||
root:render(React.createElement(Component, props) :: any)
|
root:render(React.createElement(Component, props))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
11
packages/react-charm/src/__tests__/spec.luau
Normal file
11
packages/react-charm/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
suite.describe("useSignalState", function()
|
||||||
|
require("./useSignalState.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("useSignalBinding", function()
|
||||||
|
require("./useSignalBinding.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local ReactCharm = require("../src")
|
local ReactCharm = require("../")
|
||||||
local renderHook = require("./helpers/renderHook")
|
local renderHook = require("./helpers/renderHook")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("tracks signal state", function()
|
test("tracks signal state", function()
|
||||||
local getState, setState = Charm.signal(0)
|
local getState, setState = Charm.signal(0)
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local ReactCharm = require("../src")
|
local ReactCharm = require("../")
|
||||||
local renderHook = require("./helpers/renderHook")
|
local renderHook = require("./helpers/renderHook")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("tracks signal state", function()
|
test("tracks signal state", function()
|
||||||
local getState, setState = Charm.signal(0)
|
local getState, setState = Charm.signal(0)
|
||||||
|
|
@ -1,52 +1,54 @@
|
||||||
local Charm = require("./Charm")
|
local Charm = require("./Charm")
|
||||||
local React = require("./React")
|
local React = require("./React")
|
||||||
|
|
||||||
--[[
|
--- Returns a React state that updates when the getter function updates.
|
||||||
Returns a React state that updates when the getter function updates.
|
--- Re-renders the component when the value changes.
|
||||||
Re-renders the component when the value changes.
|
---
|
||||||
|
--- If the `dependencies` array is provided, a new effect is created for the
|
||||||
|
--- current `getter` when the dependencies change. Otherwise, the effect is
|
||||||
|
--- created only once, when the component is mounted.
|
||||||
|
---
|
||||||
|
--- @param getter The signal or selector to watch.
|
||||||
|
--- @param dependencies An array of values used to memoize the getter.
|
||||||
|
--- @return The current state.
|
||||||
|
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
|
||||||
|
local value, setValue = React.useState(getter)
|
||||||
|
|
||||||
If the `dependencies` array is provided, a new effect is created for the
|
React.useLayoutEffect(function()
|
||||||
current `getter` when the dependencies change. Otherwise, the effect is
|
return Charm.effect(function()
|
||||||
created only once, when the component is mounted.
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
|
Charm.untracked(setValue, getter())
|
||||||
@param getter The signal or selector to watch.
|
end)
|
||||||
@param dependencies An array of values used to memoize the getter.
|
end, dependencies or {})
|
||||||
@return The current state.
|
|
||||||
]]
|
|
||||||
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
|
|
||||||
local value: T, setValue = React.useState(getter)
|
|
||||||
|
|
||||||
React.useLayoutEffect(function(): () -> ()
|
|
||||||
return Charm.listen(getter, setValue :: (T, T?) -> ())
|
|
||||||
end, dependencies :: { any } or {})
|
|
||||||
|
|
||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[
|
--- Returns a React binding that updates when the getter function updates.
|
||||||
Returns a React binding that updates when the getter function updates.
|
---
|
||||||
|
--- If the `dependencies` array is provided, a new effect is created for the
|
||||||
If the `dependencies` array is provided, a new effect is created for the
|
--- current `getter` when the dependencies change. Otherwise, the effect is
|
||||||
current `getter` when the dependencies change. Otherwise, the effect is
|
--- created only once, when the component is mounted.
|
||||||
created only once, when the component is mounted.
|
---
|
||||||
|
--- @param getter The atom or selector to watch.
|
||||||
@param getter The atom or selector to watch.
|
--- @param dependencies An array of values used to memoize the getter.
|
||||||
@param dependencies An array of values used to memoize the getter.
|
--- @return A binding that stores the current state.
|
||||||
@return A binding that stores the current state.
|
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
|
||||||
]]
|
|
||||||
local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
|
|
||||||
local initialValue = React.useMemo(getter, {})
|
local initialValue = React.useMemo(getter, {})
|
||||||
local binding, setBinding = React.useBinding(initialValue)
|
local binding, setBinding = React.useBinding(initialValue)
|
||||||
|
|
||||||
React.useLayoutEffect(function(): () -> ()
|
React.useLayoutEffect(function()
|
||||||
return Charm.listen(getter, setBinding :: (T, T?) -> ())
|
return Charm.effect(function()
|
||||||
end, dependencies :: { any } or {})
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
|
Charm.untracked(setBinding, getter())
|
||||||
|
end)
|
||||||
|
end, dependencies or {})
|
||||||
|
|
||||||
return binding
|
return binding
|
||||||
end
|
end
|
||||||
|
|
||||||
return table.freeze({
|
return {
|
||||||
useSignalState = useSignalState,
|
useSignalState = useSignalState,
|
||||||
useSignalBinding = useSignalBinding,
|
useSignalBinding = useSignalBinding,
|
||||||
useAtom = useSignalState,
|
useAtom = useSignalState,
|
||||||
})
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/react-charm"
|
name = "littensy/react-charm"
|
||||||
version = "0.4.1"
|
version = "0.4.0-rc.1"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
exclude = [
|
exclude = [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"test",
|
"src/__tests__",
|
||||||
"src/index.d.ts",
|
"src/index.d.ts",
|
||||||
"package.json",
|
"package.json",
|
||||||
"React.luau",
|
"React.luau",
|
||||||
|
|
@ -13,5 +13,5 @@ exclude = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
Charm = "littensy/charm@^0.11.0"
|
Charm = "littensy/charm@0.11.0-rc.1"
|
||||||
React = "jsdotlua/react@^17"
|
React = "jsdotlua/react@^17"
|
||||||
|
|
|
||||||
|
|
@ -1 +1,5 @@
|
||||||
return require(script.Parent.Parent.charm.src)
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
||||||
|
return Charm
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/vide-charm",
|
"name": "@rbxts/vide-charm",
|
||||||
"version": "0.4.1",
|
"version": "0.4.0-rc.1",
|
||||||
"description": "Vide bindings for Charm state management",
|
"description": "Vide bindings for Charm state management",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "src/init.luau",
|
"main": "src/init.luau",
|
||||||
|
|
@ -11,18 +11,18 @@
|
||||||
"!wally.toml",
|
"!wally.toml",
|
||||||
"!wally.lock",
|
"!wally.lock",
|
||||||
"!default.project.json",
|
"!default.project.json",
|
||||||
"!test"
|
"!**/__tests__"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/littensy/charm.git"
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
"publish:wally": "wally publish"
|
"publish:wally": "wally publish"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rbxts/charm": "workspace:^"
|
"@rbxts/charm": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rbxts/vide": "*"
|
"@rbxts/vide": "*"
|
||||||
|
|
|
||||||
7
packages/vide-charm/src/__tests__/spec.luau
Normal file
7
packages/vide-charm/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
suite.describe("useSignalState", function()
|
||||||
|
require("./useSignalState.spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../../Charm")
|
||||||
local Vide = require("../Vide")
|
local Vide = require("../../Vide")
|
||||||
local VideCharm = require("../src")
|
local VideCharm = require("../")
|
||||||
local test = require("../../../tests/test")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
test("tracks signal state", function()
|
test("tracks signal state", function()
|
||||||
local getState, setState = Charm.signal(0)
|
local getState, setState = Charm.signal(0)
|
||||||
local source: () -> number
|
local source
|
||||||
local cleanup = Vide.root(function()
|
local cleanup = Vide.root(function()
|
||||||
source = VideCharm.useSignalState(getState)
|
source = VideCharm.useSignalState(getState)
|
||||||
end)
|
end)
|
||||||
|
|
@ -17,7 +19,7 @@ end)
|
||||||
|
|
||||||
test("accepts a selector", function()
|
test("accepts a selector", function()
|
||||||
local getState, setState = Charm.signal(0)
|
local getState, setState = Charm.signal(0)
|
||||||
local source: () -> number
|
local source
|
||||||
local cleanup = Vide.root(function()
|
local cleanup = Vide.root(function()
|
||||||
source = VideCharm.useSignalState(function()
|
source = VideCharm.useSignalState(function()
|
||||||
return 2 * getState()
|
return 2 * getState()
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
local Charm = require("./Charm")
|
local Charm = require("./Charm")
|
||||||
local Vide = require("./Vide")
|
local Vide = require("./Vide")
|
||||||
|
|
||||||
--[[
|
--- Returns a reactive source that subscribes to the current value of the given
|
||||||
Returns a reactive source that subscribes to the current value of the given
|
--- signal or getter function.
|
||||||
signal or getter function.
|
---
|
||||||
|
--- @param getter The getter function to subscribe to.
|
||||||
@param getter The getter function to subscribe to.
|
--- @return The reactive source.
|
||||||
@return The reactive source.
|
|
||||||
]]
|
|
||||||
local function useSignalState<State>(getter: () -> State): () -> State
|
local function useSignalState<State>(getter: () -> State): () -> State
|
||||||
local state = Vide.source(getter())
|
local state = Vide.source(getter())
|
||||||
|
|
||||||
Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
|
Vide.cleanup(Charm.effect(function()
|
||||||
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
|
Charm.untracked(state :: (State) -> State, getter())
|
||||||
|
end))
|
||||||
|
|
||||||
return state
|
return state
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
local test = require("../../tests/test")
|
|
||||||
|
|
||||||
test.describe("useSignalState", function()
|
|
||||||
require("@self/useSignalState.test")
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/vide-charm"
|
name = "littensy/vide-charm"
|
||||||
version = "0.4.1"
|
version = "0.4.0-rc.1"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
exclude = [
|
exclude = [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"test",
|
"src/__tests__",
|
||||||
"src/index.d.ts",
|
"src/index.d.ts",
|
||||||
"package.json",
|
"package.json",
|
||||||
"Vide.luau",
|
"Vide.luau",
|
||||||
|
|
@ -13,5 +13,5 @@ exclude = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
Charm = "littensy/charm@^0.11.0"
|
Charm = "littensy/charm@0.11.0-rc.1"
|
||||||
Vide = "centau/vide@^0.4"
|
Vide = "centau/vide@^0.3"
|
||||||
|
|
|
||||||
1142
pnpm-lock.yaml
generated
1142
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,2 @@
|
||||||
packages:
|
packages:
|
||||||
- packages/*
|
- packages/*
|
||||||
publicHoistPattern:
|
|
||||||
- "*@rbxts*"
|
|
||||||
|
|
|
||||||
15
rokit.toml
15
rokit.toml
|
|
@ -1,6 +1,13 @@
|
||||||
|
# This file lists tools managed by Rokit, a toolchain manager for Roblox projects.
|
||||||
|
# For more information, see https://github.com/rojo-rbx/rokit
|
||||||
|
|
||||||
|
# New tools can be added by running `rokit add <tool>` in a terminal.
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
wally = "UpliftGames/wally@0.3.2"
|
wally = "UpliftGames/wally@0.3.2"
|
||||||
rojo = "rojo-rbx/rojo@7.7.0"
|
rojo = "rojo-rbx/rojo@7.6.0"
|
||||||
stylua = "johnnymorganz/stylua@2.5.2"
|
stylua = "johnnymorganz/stylua@2.3.0"
|
||||||
luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
|
selene = "Kampfkarren/selene@0.29.0"
|
||||||
lute = "luau-lang/lute@1.0.0"
|
luau-lsp = "johnnymorganz/luau-lsp@1.55.0"
|
||||||
|
lune = "lune-org/lune@0.10.4"
|
||||||
|
lute = "luau-lang/lute@0.1.0-nightly.20250923"
|
||||||
|
|
|
||||||
6
selene.toml
Normal file
6
selene.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
std = "selene_defs"
|
||||||
|
exclude = ["*/node_modules/*"]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
global_usage = "allow"
|
||||||
|
shadowing = "allow"
|
||||||
6
selene_defs.yml
Normal file
6
selene_defs.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
base: roblox
|
||||||
|
name: selene_defs
|
||||||
|
globals:
|
||||||
|
require:
|
||||||
|
args:
|
||||||
|
- type: string
|
||||||
23
test/spec.server.luau
Normal file
23
test/spec.server.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
local Charm = require("../packages/charm/src")
|
||||||
|
local suite = require("./suite")
|
||||||
|
|
||||||
|
Charm.flags.strict = true
|
||||||
|
Charm.flags.frozen = true
|
||||||
|
|
||||||
|
suite.describe("charm", function()
|
||||||
|
require("../packages/charm/src/__tests__/spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe("charm-sync", function()
|
||||||
|
require("../packages/charm-sync/src/__tests__/spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe.ingame("react-charm", function()
|
||||||
|
require("../packages/react-charm/src/__tests__/spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.describe.ingame("vide-charm", function()
|
||||||
|
require("../packages/vide-charm/src/__tests__/spec")
|
||||||
|
end)
|
||||||
|
|
||||||
|
suite.finish()
|
||||||
1
test/suite
Submodule
1
test/suite
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
local Charm = require("../packages/charm/src")
|
|
||||||
local test = require("./test")
|
|
||||||
|
|
||||||
Charm.flags.strict = true
|
|
||||||
Charm.flags.frozen = true
|
|
||||||
|
|
||||||
local describeInRoblox = test.describeIf(function()
|
|
||||||
return not not game
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.describe("charm", function()
|
|
||||||
require("../packages/charm/test")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.describe("charm-sync", function()
|
|
||||||
require("../packages/charm-sync/test")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.describe("deep-charm", function()
|
|
||||||
require("../packages/deep-charm/test")
|
|
||||||
end)
|
|
||||||
|
|
||||||
describeInRoblox("react-charm", function()
|
|
||||||
require("../packages/react-charm/test")
|
|
||||||
end)
|
|
||||||
|
|
||||||
describeInRoblox("vide-charm", function()
|
|
||||||
require("../packages/vide-charm/test")
|
|
||||||
end)
|
|
||||||
|
|
||||||
test.finish()
|
|
||||||
|
|
||||||
return {}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue