mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
Compare commits
75 commits
main
...
charm-v0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f082956ed9 | ||
|
|
b733369e70 | ||
|
|
5c90f928f7 | ||
|
|
bd23d54374 | ||
|
|
7bad215560 | ||
|
|
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 |
135 changed files with 7991 additions and 4251 deletions
26
.eslintrc
26
.eslintrc
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"jsx": true,
|
|
||||||
"useJSXTextNode": true,
|
|
||||||
"ecmaVersion": 2018,
|
|
||||||
"sourceType": "module",
|
|
||||||
"project": "./tsconfig.json"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint",
|
|
||||||
"roblox-ts",
|
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:roblox-ts/recommended",
|
|
||||||
"plugin:prettier/recommended"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"prettier/prettier": "warn",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unsafe-declaration-merging": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
||||||
* text=auto eol=lf
|
|
||||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
|
|
@ -1,31 +1,24 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
run_install: true
|
run_install: true
|
||||||
|
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
- name: Install Rokit
|
|
||||||
uses: CompeyDev/setup-rokit@v0.1.2
|
|
||||||
|
|
||||||
- name: Install Wally dependencies
|
|
||||||
run: pnpm wally
|
|
||||||
|
|
||||||
- name: Check code quality
|
- name: Check code quality
|
||||||
run: pnpm check
|
run: sh bin/check.sh
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: lune run test/spec.server.luau
|
||||||
|
|
||||||
- name: Build models
|
- name: Build models
|
||||||
run: pnpm build
|
run: sh bin/build.sh
|
||||||
|
|
|
||||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
|
|
@ -9,13 +9,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
|
|
||||||
- name: Install Rokit
|
|
||||||
uses: CompeyDev/setup-rokit@v0.1.2
|
|
||||||
|
|
||||||
- name: Build models
|
- name: Build models
|
||||||
run: pnpm 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
|
||||||
|
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,13 +1,13 @@
|
||||||
/.vscode
|
/.vscode
|
||||||
/node_modules
|
/.pesde
|
||||||
/build
|
/build
|
||||||
/modules/DevPackages
|
|
||||||
/packages/**/node_modules
|
|
||||||
/packages/**/wally.lock
|
/packages/**/wally.lock
|
||||||
/packages/**/README.md
|
|
||||||
/packages/**/LICENSE.md
|
/packages/**/LICENSE.md
|
||||||
|
/packages/**/README.md
|
||||||
/sourcemap.json
|
/sourcemap.json
|
||||||
|
node_modules
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
|
||||||
*.rbxm
|
*.rbxm
|
||||||
|
*.tgz
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
roblox.d.luau
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "test/suite"]
|
||||||
|
path = test/suite
|
||||||
|
url = https://gist.github.com/littensy/fa9afcc5d66d25e86538634982be4ee4
|
||||||
5
.luaurc
5
.luaurc
|
|
@ -1,3 +1,6 @@
|
||||||
{
|
{
|
||||||
"languageMode": "strict"
|
"languageMode": "strict",
|
||||||
|
"lint": {
|
||||||
|
"LocalShadow": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
public-hoist-pattern[]=*@rbxts*
|
||||||
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 255 KiB |
48
benches/bench.luau
Normal file
48
benches/bench.luau
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
local profiles: { [string]: number } = {}
|
||||||
|
local times: { [string]: number } = {}
|
||||||
|
local stack: { string } = {}
|
||||||
|
|
||||||
|
local function run(name: string, test: () -> ())
|
||||||
|
local start = os.clock()
|
||||||
|
local iter = 0
|
||||||
|
|
||||||
|
table.clear(profiles)
|
||||||
|
table.clear(times)
|
||||||
|
table.clear(stack)
|
||||||
|
|
||||||
|
while os.clock() - start < 2 do
|
||||||
|
iter += 1
|
||||||
|
test()
|
||||||
|
end
|
||||||
|
|
||||||
|
local elapsed = os.clock() - start
|
||||||
|
local results = {}
|
||||||
|
|
||||||
|
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||||
|
|
||||||
|
for name, elapsed in times do
|
||||||
|
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
|
||||||
|
end
|
||||||
|
|
||||||
|
print(table.concat(results, " | "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function profilebegin(name: string)
|
||||||
|
profiles[name] = os.clock()
|
||||||
|
table.insert(stack, name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function profileend()
|
||||||
|
local name = table.remove(stack)
|
||||||
|
|
||||||
|
if name then
|
||||||
|
times[name] = (times[name] or 0) + os.clock() - profiles[name]
|
||||||
|
profiles[name] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
run = run,
|
||||||
|
profilebegin = profilebegin,
|
||||||
|
profileend = profileend,
|
||||||
|
}
|
||||||
83
benches/mapped.luau
Normal file
83
benches/mapped.luau
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
local Charm = require("../packages/charm/src")
|
||||||
|
local bench = require("./bench")
|
||||||
|
|
||||||
|
local computed = Charm.computed
|
||||||
|
local mapped = Charm.mapped
|
||||||
|
local setActiveSub = Charm.setActiveSub
|
||||||
|
local signal = Charm.signal
|
||||||
|
|
||||||
|
type Entry = { number }
|
||||||
|
|
||||||
|
local count = 512
|
||||||
|
local initialState: { Entry } = table.create(count)
|
||||||
|
|
||||||
|
for i = 1, count do
|
||||||
|
initialState[i] = table.create(64, i)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mapper(value: Entry, key: number): (Entry, number)
|
||||||
|
return table.clone(value), key + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
|
||||||
|
return computed(function()
|
||||||
|
local state = source()
|
||||||
|
local nextState = {}
|
||||||
|
|
||||||
|
for index, value in state do
|
||||||
|
local currentSub = setActiveSub(nil)
|
||||||
|
local success, valueOut, keyOut = pcall(mapper, value, index)
|
||||||
|
setActiveSub(currentSub)
|
||||||
|
|
||||||
|
if success then
|
||||||
|
nextState[keyOut] = valueOut
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return state
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
|
||||||
|
return function(state)
|
||||||
|
local nextState = table.clone(state)
|
||||||
|
for index, value in state do
|
||||||
|
if math.random() < percent then
|
||||||
|
nextState[index] = table.clone(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nextState
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, percent in { 0.1, 0.5, 1 } do
|
||||||
|
bench.run(`mapped {percent}`, function()
|
||||||
|
local src, setSrc = signal(initialState)
|
||||||
|
local out = mapped(src, mapper)
|
||||||
|
|
||||||
|
bench.profilebegin("init")
|
||||||
|
out()
|
||||||
|
bench.profileend()
|
||||||
|
|
||||||
|
setSrc(updatePercentage(percent))
|
||||||
|
|
||||||
|
bench.profilebegin("update")
|
||||||
|
out()
|
||||||
|
bench.profileend()
|
||||||
|
end)
|
||||||
|
|
||||||
|
bench.run(`control {percent}`, function()
|
||||||
|
local src, setSrc = signal(initialState)
|
||||||
|
local out = simpleMapped(src, mapper)
|
||||||
|
|
||||||
|
bench.profilebegin("init")
|
||||||
|
out()
|
||||||
|
bench.profileend()
|
||||||
|
|
||||||
|
setSrc(updatePercentage(percent))
|
||||||
|
|
||||||
|
bench.profilebegin("update")
|
||||||
|
out()
|
||||||
|
bench.profileend()
|
||||||
|
end)
|
||||||
|
end
|
||||||
47
benches/propagate.luau
Normal file
47
benches/propagate.luau
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
local Charm = require("../packages/charm/src")
|
||||||
|
local bench = require("./bench")
|
||||||
|
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local listen = Charm.listen
|
||||||
|
local signal = Charm.signal
|
||||||
|
|
||||||
|
for _, w in { 10, 100 } do
|
||||||
|
for _, h in { 10, 100 } do
|
||||||
|
bench.run(`propagate {w}*{h}`, function()
|
||||||
|
local src, setSrc = signal(1)
|
||||||
|
for _ = 1, w do
|
||||||
|
local last = src
|
||||||
|
for _ = 1, h do
|
||||||
|
local prev = last
|
||||||
|
last = computed(function()
|
||||||
|
return prev() + 1
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
effect(function()
|
||||||
|
last()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
setSrc(src() + 1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, w in { 10, 100 } do
|
||||||
|
for _, h in { 10, 100 } do
|
||||||
|
bench.run(`propagate (listen) {w}*{h}`, function()
|
||||||
|
local src, setSrc = signal(1)
|
||||||
|
for _ = 1, w do
|
||||||
|
local last = src
|
||||||
|
for _ = 1, h do
|
||||||
|
local prev = last
|
||||||
|
last = computed(function()
|
||||||
|
return prev() + 1
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
listen(last, function() end)
|
||||||
|
end
|
||||||
|
setSrc(src() + 1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
87
benches/reactive.luau
Normal file
87
benches/reactive.luau
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
local Charm = require("../packages/charm/src")
|
||||||
|
local bench = require("./bench")
|
||||||
|
|
||||||
|
local effect = Charm.effect
|
||||||
|
local reactive = Charm.reactive
|
||||||
|
|
||||||
|
do
|
||||||
|
local value = reactive({ a = 1 })
|
||||||
|
bench.run("track reactive property", function()
|
||||||
|
effect(function()
|
||||||
|
local _ = value.a
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local value = reactive({ a = 1 })
|
||||||
|
bench.run("write reactive property", function()
|
||||||
|
value.a += 1
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, amount in { 10, 100, 1000 } do
|
||||||
|
print("---")
|
||||||
|
|
||||||
|
do
|
||||||
|
local rawArray = table.create(amount, 1)
|
||||||
|
local array = reactive(rawArray)
|
||||||
|
|
||||||
|
bench.run(`track for loop, {amount} elements`, function()
|
||||||
|
local sum = 0
|
||||||
|
effect(function()
|
||||||
|
for i = 1, #array do
|
||||||
|
sum += array[i]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local rawArray = table.create(amount, 1)
|
||||||
|
local array = reactive(rawArray)
|
||||||
|
|
||||||
|
bench.run(`track iteration, {amount} elements`, function()
|
||||||
|
local sum = 0
|
||||||
|
effect(function()
|
||||||
|
for _, x in array do
|
||||||
|
sum += x
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local rawArray = table.create(amount, 1)
|
||||||
|
local array = reactive(rawArray)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
local sum = 0
|
||||||
|
for _, x in array do
|
||||||
|
sum += x
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bench.run(`track index mutation (1st only), {amount} elements`, function()
|
||||||
|
array[1] += 1
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local rawArray = table.create(amount, 1)
|
||||||
|
local array = reactive(rawArray)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
local sum = 0
|
||||||
|
for _, x in array do
|
||||||
|
sum += x
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bench.run(`track index mutation (all), {amount} elements`, function()
|
||||||
|
for i = 1, #array do
|
||||||
|
array[i] += 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
mkdir build
|
mkdir build
|
||||||
rojo build packages/charm -o build/charm.rbxm
|
rojo build build.project.json -o build/charm.rbxm
|
||||||
rojo build packages/charm-sync -o build/charm-sync.rbxm
|
|
||||||
rojo build packages/react -o build/react-charm.rbxm
|
|
||||||
rojo build packages/vide -o build/vide-charm.rbxm
|
|
||||||
|
|
|
||||||
32
bin/check.sh
32
bin/check.sh
|
|
@ -1,29 +1,19 @@
|
||||||
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua
|
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
|
||||||
|
|
||||||
rojo sourcemap -o sourcemap.json
|
rojo sourcemap -o sourcemap.json
|
||||||
|
|
||||||
check() {
|
luau-lsp analyze \
|
||||||
echo "Checking $1"
|
--defs=bin/roblox.d.luau \
|
||||||
|
--flag:LuauFixIndexerSubtypingOrdering=true \
|
||||||
|
--flag:LuauInstantiateInSubtyping=true \
|
||||||
|
--sourcemap=sourcemap.json \
|
||||||
|
--ignore="**/node_modules/**" \
|
||||||
|
packages test benches
|
||||||
|
|
||||||
luau-lsp analyze \
|
selene packages test benches
|
||||||
--defs=bin/roblox.d.luau \
|
|
||||||
--defs=bin/testez.d.luau \
|
|
||||||
--flag:LuauFixIndexerSubtypingOrdering=true \
|
|
||||||
--flag:LuauInstantiateInSubtyping=true \
|
|
||||||
--sourcemap=sourcemap.json \
|
|
||||||
--ignore="**/_Index/**" \
|
|
||||||
$1
|
|
||||||
|
|
||||||
selene $1
|
stylua --check packages test benches
|
||||||
stylua --check $1
|
|
||||||
}
|
|
||||||
|
|
||||||
eslint packages
|
pnpm eslint packages
|
||||||
|
|
||||||
for i in packages/*/src; do
|
|
||||||
check $i
|
|
||||||
done
|
|
||||||
|
|
||||||
check tests
|
|
||||||
|
|
||||||
rm bin/roblox.d.luau
|
rm bin/roblox.d.luau
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
cd modules
|
|
||||||
wally install
|
|
||||||
cd ..
|
|
||||||
rojo sourcemap --output sourcemap.json
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
declare function afterAll(callback: () -> ()): ()
|
|
||||||
declare function afterEach(callback: () -> ()): ()
|
|
||||||
|
|
||||||
declare function beforeAll(callback: () -> ()): ()
|
|
||||||
declare function beforeEach(callback: () -> ()): ()
|
|
||||||
|
|
||||||
declare function describe(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function describeFOCUS(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function fdescribe(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function describeSKIP(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function xdescribe(phrase: string, callback: () -> ()): ()
|
|
||||||
|
|
||||||
declare function expect(value: any): any
|
|
||||||
|
|
||||||
declare function FIXME(optionalMessage: string?): ()
|
|
||||||
declare function FOCUS(): ()
|
|
||||||
declare function SKIP(): ()
|
|
||||||
|
|
||||||
declare function it(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function itFOCUS(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function fit(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function itSKIP(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function xit(phrase: string, callback: () -> ()): ()
|
|
||||||
declare function itFIXME(phrase: string, callback: () -> ()): ()
|
|
||||||
10
build.project.json
Normal file
10
build.project.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "Packages",
|
||||||
|
"tree": {
|
||||||
|
"$className": "Folder",
|
||||||
|
"Charm": { "$path": "packages/charm/src" },
|
||||||
|
"CharmSync": { "$path": "packages/charm-sync/src" },
|
||||||
|
"ReactCharm": { "$path": "packages/react-charm/src" },
|
||||||
|
"VideCharm": { "$path": "packages/vide-charm/src" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,50 @@
|
||||||
{
|
{
|
||||||
"name": "charm-test",
|
"name": "charm-test",
|
||||||
|
"globIgnorePaths": [
|
||||||
|
"**/default.project.json",
|
||||||
|
"node_modules/charm",
|
||||||
|
"node_modules/charm-sync",
|
||||||
|
"node_modules/react-charm",
|
||||||
|
"node_modules/vide-charm"
|
||||||
|
],
|
||||||
|
"emitLegacyScripts": false,
|
||||||
"tree": {
|
"tree": {
|
||||||
"$className": "DataModel",
|
"$className": "DataModel",
|
||||||
"ReplicatedStorage": {
|
"ReplicatedStorage": {
|
||||||
"$className": "ReplicatedStorage",
|
"$className": "ReplicatedStorage",
|
||||||
"DevPackages": {
|
"packages": {
|
||||||
"$path": "modules/DevPackages",
|
"$className": "Folder",
|
||||||
"Charm": {
|
"vide": {
|
||||||
"$path": "packages/charm"
|
"$path": "node_modules/@rbxts/vide"
|
||||||
},
|
},
|
||||||
"CharmSync": {
|
"charm": {
|
||||||
"$path": "packages/charm-sync"
|
"$className": "Folder",
|
||||||
|
"src": { "$path": "packages/charm/src" }
|
||||||
},
|
},
|
||||||
"ReactCharm": {
|
"charm-sync": {
|
||||||
"$path": "packages/react"
|
"$className": "Folder",
|
||||||
|
"src": { "$path": "packages/charm-sync/src" },
|
||||||
|
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
|
||||||
},
|
},
|
||||||
"VideCharm": {
|
"react-charm": {
|
||||||
"$path": "packages/vide"
|
"$className": "Folder",
|
||||||
|
"src": { "$path": "packages/react-charm/src" },
|
||||||
|
"React": { "$path": "packages/react-charm/React.luau" },
|
||||||
|
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
|
||||||
|
"Charm": { "$path": "packages/react-charm/Charm.luau" }
|
||||||
|
},
|
||||||
|
"vide-charm": {
|
||||||
|
"$className": "Folder",
|
||||||
|
"src": { "$path": "packages/vide-charm/src" },
|
||||||
|
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
|
||||||
|
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
"@rbxts-js": {
|
||||||
"ServerScriptService": {
|
"$path": "node_modules/@rbxts-js"
|
||||||
"$className": "ServerScriptService",
|
},
|
||||||
"Tests": {
|
"test": {
|
||||||
"$path": "tests"
|
"$path": "test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
||||||
|
);
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
# This file is automatically @generated by Wally.
|
|
||||||
# It is not intended for manual editing.
|
|
||||||
registry = "test"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "centau/vide"
|
|
||||||
version = "0.3.1"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/boolean"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = [["number", "jsdotlua/number@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/collections"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/console"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = [["collections", "jsdotlua/collections@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/es7-types"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/instance-of"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/luau-polyfill"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = [["boolean", "jsdotlua/boolean@1.2.7"], ["collections", "jsdotlua/collections@1.2.7"], ["console", "jsdotlua/console@1.2.7"], ["es7-types", "jsdotlua/es7-types@1.2.7"], ["instance-of", "jsdotlua/instance-of@1.2.7"], ["math", "jsdotlua/math@1.2.7"], ["number", "jsdotlua/number@1.2.7"], ["string", "jsdotlua/string@1.2.7"], ["symbol-luau", "jsdotlua/symbol-luau@1.0.1"], ["timers", "jsdotlua/timers@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/math"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/number"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/promise"
|
|
||||||
version = "3.5.2"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/react"
|
|
||||||
version = "17.2.1"
|
|
||||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/react-reconciler"
|
|
||||||
version = "17.2.1"
|
|
||||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/react-roblox"
|
|
||||||
version = "17.2.1"
|
|
||||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["react", "jsdotlua/react@17.2.1"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.1"], ["scheduler", "jsdotlua/scheduler@17.2.1"], ["shared", "jsdotlua/shared@17.2.1"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/scheduler"
|
|
||||||
version = "17.2.1"
|
|
||||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"], ["shared", "jsdotlua/shared@17.2.1"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/shared"
|
|
||||||
version = "17.2.1"
|
|
||||||
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/string"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = [["es7-types", "jsdotlua/es7-types@1.2.7"], ["number", "jsdotlua/number@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/symbol-luau"
|
|
||||||
version = "1.0.1"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsdotlua/timers"
|
|
||||||
version = "1.2.7"
|
|
||||||
dependencies = [["collections", "jsdotlua/collections@1.2.7"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "littensy/charm"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [["React", "jsdotlua/react@17.2.1"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.1"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "roblox/testez"
|
|
||||||
version = "0.4.1"
|
|
||||||
dependencies = []
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "littensy/charm"
|
|
||||||
version = "0.1.0"
|
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
|
||||||
realm = "shared"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
React = "jsdotlua/react@17.2.0"
|
|
||||||
ReactRoblox = "jsdotlua/react-roblox@17.2.0"
|
|
||||||
TestEZ = "roblox/testez@0.4.1"
|
|
||||||
Vide = "centau/vide@0.3.1"
|
|
||||||
30
package.json
30
package.json
|
|
@ -1,24 +1,30 @@
|
||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "lune run test/spec.server.luau",
|
||||||
"build": "sh bin/build.sh",
|
"build": "sh bin/build.sh",
|
||||||
"check": "sh bin/check.sh",
|
"check": "sh bin/check.sh",
|
||||||
"wally": "sh bin/install.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@10.22.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
"@rbxts/compiler-types": "3.0.0-types.0",
|
"@rbxts/compiler-types": "3.0.0-types.0",
|
||||||
"@rbxts/types": "^1.0.811",
|
"@rbxts/react": "17.3.5-alpha.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@rbxts/react-roblox": "17.3.5-alpha.1",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@rbxts/types": "^1.0.883",
|
||||||
"eslint": "^8.57.1",
|
"@rbxts/vide": "^0.5.7",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"@typescript-eslint/parser": "^8.44.1",
|
||||||
"eslint-plugin-roblox-ts": "^0.0.36",
|
"eslint": "^9.36.0",
|
||||||
"prettier": "^3.3.3",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"eslint-plugin-roblox-ts": "^1.2.1",
|
||||||
|
"jiti": "^2.6.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"roblox-ts": "^3.0.0",
|
"roblox-ts": "^3.0.0",
|
||||||
"typescript": "~5.5.4"
|
"typescript": "^5.9.2",
|
||||||
},
|
"typescript-eslint": "^8.44.1"
|
||||||
"packageManager": "pnpm@9.15.4"
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
packages/charm-sync/Charm.luau
Normal file
9
packages/charm-sync/Charm.luau
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
if not script then
|
||||||
|
return require("../charm/src")
|
||||||
|
end
|
||||||
|
|
||||||
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
||||||
|
return Charm
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "CharmSync",
|
"name": "charm-sync",
|
||||||
"tree": {
|
"tree": {
|
||||||
"$path": "src"
|
"$path": "src"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/charm-sync",
|
"name": "@rbxts/charm-sync",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0-rc.2",
|
||||||
"description": "Sync server state with clients using Charm",
|
"description": "Simple server-to-client sync for Charm",
|
||||||
"repository": "littensy/charm",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "src/init.luau",
|
"main": "src/init.luau",
|
||||||
"types": "src/index.d.ts",
|
"types": "src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"*.luau",
|
||||||
|
"!wally.toml",
|
||||||
|
"!wally.lock",
|
||||||
|
"!default.project.json",
|
||||||
|
"!**/__tests__"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
"publish:wally": "wally publish"
|
"publish:wally": "wally publish"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"dependencies": {
|
||||||
"@rbxts/charm": "workspace:*"
|
"@rbxts/charm": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
packages/charm-sync/src/__tests__/client.spec.luau
Normal file
139
packages/charm-sync/src/__tests__/client.spec.luau
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local client = require("../client")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
client.removeAllSignals()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should hydrate atom", function()
|
||||||
|
local a = Charm.atom(1)
|
||||||
|
|
||||||
|
client.addSignals({ a = a })
|
||||||
|
client.patch({
|
||||||
|
{ type = "init", data = { a = 2 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a() == 2, "should set a to the initial value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should merge patch", function()
|
||||||
|
local a = Charm.atom({ b = 1, c = 2 })
|
||||||
|
local previous = a()
|
||||||
|
|
||||||
|
client.addSignals({ a = a })
|
||||||
|
client.patch({
|
||||||
|
{ type = "init", data = { a = a() } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a() == previous, "should set a to the initial value")
|
||||||
|
|
||||||
|
client.patch({
|
||||||
|
{ type = "patch", data = { a = { b = 3 } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a() ~= previous, "should apply patch")
|
||||||
|
assert(a().b == 3, "should set b to the new value")
|
||||||
|
assert(a().c == 2, "should not change c")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should merge patch (reactive)", function()
|
||||||
|
local a = Charm.reactive({ b = 1, c = 2 })
|
||||||
|
local runs = 0
|
||||||
|
|
||||||
|
Charm.effect(function()
|
||||||
|
local _ = a.b + a.c
|
||||||
|
runs += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
client.addSignals({ a = a })
|
||||||
|
client.patch({
|
||||||
|
{ type = "init", data = { a = { b = a.b, c = a.c } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(runs == 1, "should not trigger effect for redundant init")
|
||||||
|
|
||||||
|
client.patch({
|
||||||
|
{ type = "patch", data = { a = { b = 3 } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a.b == 3, "should set b to the new value")
|
||||||
|
assert(a.c == 2, "should not change c")
|
||||||
|
assert(runs == 2, "should trigger effect for patch")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should fix sparse array patches", function()
|
||||||
|
local a = Charm.atom({ 1 })
|
||||||
|
|
||||||
|
client.addSignals({ a = a })
|
||||||
|
client.patch({
|
||||||
|
{ type = "init", data = { a = a() } },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
|
||||||
|
-- was JSON-serialized and deserialized. The client should be able to catch
|
||||||
|
-- this and convert the string key back to a number.
|
||||||
|
client.patch({
|
||||||
|
{ type = "patch", data = { a = { ["2"] = 2 } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
|
||||||
|
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should merge multiple patches", function()
|
||||||
|
local a = Charm.atom(1)
|
||||||
|
local calls = {}
|
||||||
|
|
||||||
|
client.addSignals({ a = a })
|
||||||
|
|
||||||
|
Charm.subscribe(a, function(value)
|
||||||
|
table.insert(calls, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
client.patch({
|
||||||
|
{ type = "init", data = { a = a() } },
|
||||||
|
})
|
||||||
|
|
||||||
|
client.patch({
|
||||||
|
{ type = "patch", data = { a = 2 } },
|
||||||
|
{ type = "patch", data = { a = 3 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a() == 3, "should apply the last patch")
|
||||||
|
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should batch patches", function()
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local b = Charm.atom(0)
|
||||||
|
local states = {}
|
||||||
|
|
||||||
|
client.addSignals({ a = a, b = b })
|
||||||
|
|
||||||
|
Charm.subscribe(a, function()
|
||||||
|
states.b = b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
Charm.subscribe(b, function()
|
||||||
|
states.a = a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
client.patch({
|
||||||
|
{ type = "init", data = { a = a(), b = b() } },
|
||||||
|
})
|
||||||
|
|
||||||
|
client.patch({
|
||||||
|
{ type = "patch", data = { a = 1, b = 2 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert(a() == 1, "should set a to the new value")
|
||||||
|
assert(b() == 2, "should set b to the new value")
|
||||||
|
assert(states.a == 1, "sub to a should run after b updates")
|
||||||
|
assert(states.b == 2, "sub to b should run after a updates")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
99
packages/charm-sync/src/__tests__/e2e.spec.luau
Normal file
99
packages/charm-sync/src/__tests__/e2e.spec.luau
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local client = require("../client")
|
||||||
|
local config = require("../config")
|
||||||
|
local server = require("../server")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
server.reset()
|
||||||
|
client.removeAllSignals()
|
||||||
|
config.preserveHistory = false
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate server state to client", function()
|
||||||
|
local target = {}
|
||||||
|
local serverA = Charm.atom({ count = 0 })
|
||||||
|
local serverB = Charm.reactive({ count = 0 })
|
||||||
|
local clientA = Charm.atom({ count = -1 })
|
||||||
|
local clientB = Charm.reactive({ count = -1 })
|
||||||
|
|
||||||
|
client.addSignals({ a = clientA, b = clientB })
|
||||||
|
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||||
|
server.connect(function(syncTarget, payloads)
|
||||||
|
if syncTarget == target then
|
||||||
|
client.patch(payloads)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(clientA().count == 0, "Client atom A should be initialized to 0")
|
||||||
|
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
|
||||||
|
|
||||||
|
serverA({ count = 1 })
|
||||||
|
serverB.count = 1
|
||||||
|
server.flush()
|
||||||
|
assert(clientA().count == 1, "Client atom A should be updated to 1")
|
||||||
|
assert(clientB.count == 1, "Client reactive B should be updated to 1")
|
||||||
|
|
||||||
|
local runs = -1
|
||||||
|
Charm.effect(function()
|
||||||
|
local _ = clientB.count
|
||||||
|
runs += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
serverA({ count = 2 })
|
||||||
|
server.flush()
|
||||||
|
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||||
|
assert(runs == 0, "Client reactive B should not trigger update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate historical server state to client", function()
|
||||||
|
local target = {}
|
||||||
|
local serverA = Charm.atom({ count = 0 })
|
||||||
|
local serverB = Charm.reactive({ count = 0 })
|
||||||
|
local clientA = Charm.atom({ count = -1 })
|
||||||
|
local clientB = Charm.reactive({ count = -1 })
|
||||||
|
|
||||||
|
config.preserveHistory = true
|
||||||
|
|
||||||
|
client.addSignals({ a = clientA, b = clientB })
|
||||||
|
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||||
|
server.connect(function(syncTarget, payloads)
|
||||||
|
if syncTarget == target then
|
||||||
|
client.patch(payloads)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
local expectedA = { -1, 0, 1, 2, 3, 4 }
|
||||||
|
local expectedB = { -1, 0, 1, 2 }
|
||||||
|
|
||||||
|
Charm.effect(function()
|
||||||
|
local expected = table.remove(expectedA, 1)
|
||||||
|
assert(expected == clientA().count, `Client reactive A should be {expected}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Charm.effect(function()
|
||||||
|
local expected = table.remove(expectedB, 1)
|
||||||
|
assert(expected == clientB.count, `Client reactive B should be {expected}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
|
||||||
|
serverA({ count = 1 })
|
||||||
|
serverA({ count = 2 })
|
||||||
|
serverB.count = 1
|
||||||
|
serverB.count = 2
|
||||||
|
server.flush()
|
||||||
|
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||||
|
assert(clientB.count == 2, "Client reactive B should be updated to 2")
|
||||||
|
|
||||||
|
serverA({ count = 3 })
|
||||||
|
serverA({ count = 4 })
|
||||||
|
server.flush()
|
||||||
|
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
130
packages/charm-sync/src/__tests__/patch.spec.luau
Normal file
130
packages/charm-sync/src/__tests__/patch.spec.luau
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
local config = require("../config")
|
||||||
|
local patch = require("../patch")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
|
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||||
|
if object[1] then
|
||||||
|
local copy = {}
|
||||||
|
for index = 1, #object do
|
||||||
|
copy[index] = object[index]
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
elseif table.maxn(object) ~= #object then
|
||||||
|
local copy = {}
|
||||||
|
for key, value in object do
|
||||||
|
copy[tostring(key)] = value
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
else
|
||||||
|
return object
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
config.fixArrays = true
|
||||||
|
config.validatePatches = true
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("returns a patch", function()
|
||||||
|
local prevState = { foo = "bar", baz = "qux" }
|
||||||
|
local nextState = { foo = "bar", baz = "quux" }
|
||||||
|
local diffs = patch.diff(prevState, nextState)
|
||||||
|
|
||||||
|
assert(diffs ~= nil, "should return a patch")
|
||||||
|
assert(diffs.baz == "quux", "should return the changed value")
|
||||||
|
assert(diffs.foo == nil, "should omit unchanged values")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("applies a patch", function()
|
||||||
|
local prevState = { foo = "bar", baz = "qux" }
|
||||||
|
local diffs = { baz = "quux", qaz = "qux" }
|
||||||
|
local applied = patch.apply(prevState, diffs)
|
||||||
|
|
||||||
|
assert(applied ~= nil, "should return a new state")
|
||||||
|
assert(applied.baz == "quux", "should apply the changed value")
|
||||||
|
assert(applied.foo == "bar", "should keep the unchanged value")
|
||||||
|
assert(applied.qaz == "qux", "should add the new value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("allows nested diffs", function()
|
||||||
|
local prevState = { foo = { bar = "baz" } }
|
||||||
|
local nextState = { foo = { bar = "qux" } }
|
||||||
|
local diffs = patch.diff(prevState, nextState)
|
||||||
|
local applied = patch.apply(prevState, diffs)
|
||||||
|
|
||||||
|
assert(applied.foo.bar == "qux", "should apply the nested patch")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("preserves array density when applying", function()
|
||||||
|
local prevState = { "a", "b", "c", "d" }
|
||||||
|
local nextState = { ["2"] = "B", ["4"] = "D" }
|
||||||
|
local applied = patch.apply(prevState, nextState)
|
||||||
|
|
||||||
|
assert(applied[1] == "a", "should keep a")
|
||||||
|
assert(applied[2] == "B", "should apply B")
|
||||||
|
assert(applied[3] == "c", "should keep c")
|
||||||
|
assert(applied[4] == "D", "should apply D")
|
||||||
|
|
||||||
|
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
|
||||||
|
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("preserves array density after serdes", function()
|
||||||
|
-- This particular change results in a patch that cannot be serialized
|
||||||
|
-- without losing information. Charm resolves this issue internally.
|
||||||
|
local prevState = { "a", "b", "c", "d", "e" }
|
||||||
|
local nextState = { "A", "b", "c", "D" }
|
||||||
|
|
||||||
|
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
|
local applied = patch.apply(prevState, diffs)
|
||||||
|
|
||||||
|
for index in prevState do
|
||||||
|
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||||
|
end
|
||||||
|
|
||||||
|
for index in applied do
|
||||||
|
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("cannot preserve sparse arrays", function()
|
||||||
|
config.validatePatches = false
|
||||||
|
|
||||||
|
local prevState = {}
|
||||||
|
local nextState = { [2] = "foo" }
|
||||||
|
|
||||||
|
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
|
local applied = patch.apply(prevState, diffs)
|
||||||
|
|
||||||
|
-- Not enough information is available to determine whether 'diffs'
|
||||||
|
-- should keep the string keys added during remote event serialization.
|
||||||
|
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
|
||||||
|
assert(applied[2] == nil, "will fail to convert string keys to numbers")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("optionally fixes sparse arrays", function()
|
||||||
|
config.fixArrays = true
|
||||||
|
|
||||||
|
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||||
|
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
||||||
|
end
|
||||||
|
|
||||||
|
config.fixArrays = false
|
||||||
|
|
||||||
|
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||||
|
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("patches mutable state", function()
|
||||||
|
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
|
||||||
|
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
|
||||||
|
assert(targetState.baz == "quux", "should apply the changed value")
|
||||||
|
assert(targetState.foo == "bar", "should keep the unchanged value")
|
||||||
|
assert(targetState.qaz == "qux", "should add the new value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
349
packages/charm-sync/src/__tests__/server.spec.luau
Normal file
349
packages/charm-sync/src/__tests__/server.spec.luau
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local config = require("../config")
|
||||||
|
local patch = require("../patch")
|
||||||
|
local server = require("../server")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
local beforeEach = suite.beforeEach
|
||||||
|
local describe = suite.describe
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
server.reset()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate state changes", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local b = Charm.atom(0)
|
||||||
|
local c = Charm.atom(0)
|
||||||
|
local target, payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||||
|
server.connect(function(...)
|
||||||
|
target, payloads = ...
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||||
|
assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'")
|
||||||
|
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||||
|
assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
|
||||||
|
|
||||||
|
a(1)
|
||||||
|
b(2)
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||||
|
assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate partial state patches", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom({ x = 0, y = 0 })
|
||||||
|
local b = Charm.atom(0 :: number?)
|
||||||
|
local target, payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a, b = b })
|
||||||
|
server.connect(function(...)
|
||||||
|
target, payloads = ...
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||||
|
assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'")
|
||||||
|
assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
|
||||||
|
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||||
|
|
||||||
|
a({ x = 1, y = 0 })
|
||||||
|
b(nil)
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||||
|
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'")
|
||||||
|
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
|
||||||
|
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate partial state patches (reactive)", function()
|
||||||
|
local client = {}
|
||||||
|
local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
|
||||||
|
local target, payloads
|
||||||
|
|
||||||
|
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 == a.x, "Expected init payload to contain value 'a.x'")
|
||||||
|
assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'")
|
||||||
|
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
|
||||||
|
|
||||||
|
a.x = 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 == 1, "Expected patch payload to contain value 'a.x'")
|
||||||
|
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'")
|
||||||
|
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should init new clients", function()
|
||||||
|
local clientA = {}
|
||||||
|
local clientB = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
|
server.addSignalsToClient(clientA, { a = a })
|
||||||
|
server.connect(function(target, payloads)
|
||||||
|
if target == clientA then
|
||||||
|
payloadsA = payloads
|
||||||
|
elseif target == clientB then
|
||||||
|
payloadsB = payloads
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
a(1)
|
||||||
|
server.flush()
|
||||||
|
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||||
|
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||||
|
|
||||||
|
a(2)
|
||||||
|
server.addSignalsToClient(clientB, { a = a })
|
||||||
|
server.flush()
|
||||||
|
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||||
|
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
||||||
|
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||||
|
assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should init new atoms", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local b = Charm.atom(0)
|
||||||
|
local target, payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a })
|
||||||
|
server.connect(function(...)
|
||||||
|
target, payloads = ...
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
server.addSignalsToClient(client, { b = b })
|
||||||
|
a(1)
|
||||||
|
b(1)
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||||
|
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||||
|
assert(payloads[2].type == "patch", "Expected second payload to be patch")
|
||||||
|
assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should only replicate to synced clients", function()
|
||||||
|
local clientA = {}
|
||||||
|
local clientB = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local b = Charm.atom(0)
|
||||||
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
|
server.addSignalsToClient(clientA, { a = a })
|
||||||
|
server.addSignalsToClient(clientB, { b = b })
|
||||||
|
server.connect(function(target, payloads)
|
||||||
|
if target == clientA then
|
||||||
|
payloadsA = payloads
|
||||||
|
elseif target == clientB then
|
||||||
|
payloadsB = payloads
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||||
|
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||||
|
assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A")
|
||||||
|
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||||
|
assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
|
||||||
|
assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
|
||||||
|
|
||||||
|
payloadsA = {}
|
||||||
|
payloadsB = {}
|
||||||
|
a(1)
|
||||||
|
server.flush()
|
||||||
|
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||||
|
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
||||||
|
assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A")
|
||||||
|
assert(#payloadsB == 0, "Expected no payloads for client B")
|
||||||
|
|
||||||
|
payloadsA = {}
|
||||||
|
payloadsB = {}
|
||||||
|
b(1)
|
||||||
|
server.flush()
|
||||||
|
assert(#payloadsA == 0, "Expected no payloads for client A")
|
||||||
|
assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
|
||||||
|
assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
|
||||||
|
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should serialize state", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||||
|
local payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a })
|
||||||
|
server.connect(function(_, p)
|
||||||
|
payloads = p
|
||||||
|
end)
|
||||||
|
server.flush()
|
||||||
|
|
||||||
|
a({ 1, 2, 0, 4 })
|
||||||
|
server.flush()
|
||||||
|
assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
|
||||||
|
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe("historical", function()
|
||||||
|
beforeEach(function()
|
||||||
|
config.preserveHistory = true
|
||||||
|
end)
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
config.preserveHistory = false
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate granular state changes", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local b = Charm.atom(0)
|
||||||
|
local c = Charm.atom(0)
|
||||||
|
local target, payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||||
|
server.connect(function(...)
|
||||||
|
target, payloads = ...
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||||
|
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
||||||
|
assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
|
||||||
|
assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
|
||||||
|
|
||||||
|
a(1)
|
||||||
|
a(2)
|
||||||
|
b(2)
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||||
|
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should replicate granular state changes (reactive)", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.reactive({ value = 0 })
|
||||||
|
local b = Charm.reactive({ value = 0 })
|
||||||
|
local c = Charm.reactive({ value = 0 })
|
||||||
|
local target, payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||||
|
server.connect(function(...)
|
||||||
|
target, payloads = ...
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||||
|
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
|
||||||
|
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
|
||||||
|
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
|
||||||
|
|
||||||
|
a.value = 1
|
||||||
|
a.value = 2
|
||||||
|
b.value = 2
|
||||||
|
server.flush()
|
||||||
|
assert(target == client, "Expected target to be the client")
|
||||||
|
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||||
|
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should init new clients", function()
|
||||||
|
local clientA = {}
|
||||||
|
local clientB = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
|
server.addSignalsToClient(clientA, { a = a })
|
||||||
|
server.connect(function(target, payloads)
|
||||||
|
if target == clientA then
|
||||||
|
payloadsA = payloads
|
||||||
|
elseif target == clientB then
|
||||||
|
payloadsB = payloads
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
a(1)
|
||||||
|
a(2)
|
||||||
|
server.flush()
|
||||||
|
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||||
|
assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
|
||||||
|
assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
|
||||||
|
assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
|
||||||
|
|
||||||
|
server.addSignalsToClient(clientB, { a = a })
|
||||||
|
a(3)
|
||||||
|
a(4)
|
||||||
|
server.flush()
|
||||||
|
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||||
|
assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
|
||||||
|
assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
|
||||||
|
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||||
|
assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
|
||||||
|
assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
|
||||||
|
assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should init new atoms", function()
|
||||||
|
local client = {}
|
||||||
|
local a = Charm.atom(0)
|
||||||
|
local b = Charm.atom(0)
|
||||||
|
local payloads
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { a = a })
|
||||||
|
server.connect(function(_, ...)
|
||||||
|
payloads = ...
|
||||||
|
end)
|
||||||
|
|
||||||
|
server.flush()
|
||||||
|
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||||
|
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
||||||
|
|
||||||
|
server.addSignalsToClient(client, { b = b })
|
||||||
|
a(1)
|
||||||
|
a(2)
|
||||||
|
b(1)
|
||||||
|
b(2)
|
||||||
|
server.flush()
|
||||||
|
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||||
|
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
|
||||||
|
assert(payloads[3].type == "init", "Expected third payload to be init")
|
||||||
|
assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
|
||||||
|
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
|
||||||
|
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
23
packages/charm-sync/src/__tests__/spec.luau
Normal file
23
packages/charm-sync/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
return {}
|
||||||
23
packages/charm-sync/src/__tests__/utils.spec.luau
Normal file
23
packages/charm-sync/src/__tests__/utils.spec.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
local utils = require("../utils")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should flatten functions", function()
|
||||||
|
local input = {
|
||||||
|
a = Charm.atom(0),
|
||||||
|
b = { c = Charm.atom(0) },
|
||||||
|
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
|
||||||
|
}
|
||||||
|
local result = utils.flatten(input)
|
||||||
|
|
||||||
|
assert(result.a == input.a, "should flatten the first level")
|
||||||
|
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
||||||
|
assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
|
||||||
|
|
||||||
|
assert(result.b == nil, "should remove the original nested tables")
|
||||||
|
assert(result.d == nil, "should remove the original nested tables")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,83 +1,105 @@
|
||||||
local Charm = require(script.Parent.Parent.Charm)
|
local Charm = require("../Charm")
|
||||||
local patch = require(script.Parent.patch)
|
local patch = require("./patch")
|
||||||
local types = require(script.Parent.types)
|
local types = require("./types")
|
||||||
type AtomMap = types.AtomMap
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
type ClientOptions = {
|
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
|
||||||
--[=[
|
|
||||||
The atoms to synchronize with the server.
|
|
||||||
]=]
|
|
||||||
atoms: AtomMap,
|
|
||||||
--[=[
|
|
||||||
Whether to ignore patches sent before the client has been hydrated.
|
|
||||||
Default is `true`.
|
|
||||||
]=]
|
|
||||||
ignoreUnhydrated: boolean?,
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClientSyncer = {
|
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
|
||||||
--[=[
|
|
||||||
Applies a patch or initializes the state of the atoms with the given
|
|
||||||
payload from the server.
|
|
||||||
|
|
||||||
@param ...payloads The patches or hydration payloads to apply.
|
|
||||||
]=]
|
|
||||||
sync: (self: ClientSyncer, ...SyncPayload) -> (),
|
|
||||||
}
|
|
||||||
|
|
||||||
--[=[
|
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
|
||||||
Creates a `ClientSyncer` object that receives patches from the server and
|
|
||||||
applies them to the local state.
|
|
||||||
|
|
||||||
@param options The atoms to synchronize with the server.
|
|
||||||
@return A `ClientSyncer` object.
|
|
||||||
]=]
|
|
||||||
local function client(options: ClientOptions): ClientSyncer
|
|
||||||
local atoms = options.atoms
|
|
||||||
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
|
|
||||||
|
|
||||||
local self = {} :: ClientSyncer
|
local trackedSignals: { [string]: SignalSetter } = {}
|
||||||
local hydrated = false
|
local trackedReactives: { [string]: ReactiveSetter } = {}
|
||||||
|
|
||||||
local function hydrate(state: { [string | number]: any })
|
--[[
|
||||||
hydrated = true
|
Registers a map of setters to the state with the corresponding keys on the
|
||||||
|
server. When the server sends updates, these setters will be called with
|
||||||
|
the new values.
|
||||||
|
|
||||||
for key, value in next, state do
|
Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||||
local atom = atoms[key]
|
Note that `server.addSignalsToClient` still requires some way to get the
|
||||||
atom(value)
|
state.
|
||||||
|
|
||||||
|
@param signals A map of setter functions to sync with the server.
|
||||||
|
]]
|
||||||
|
local function addSignals(signals: { [string]: SignalSetter | { [any]: any } })
|
||||||
|
for key, signal in signals do
|
||||||
|
if type(signal) == "function" then
|
||||||
|
trackedSignals[key] = signal
|
||||||
|
else
|
||||||
|
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function apply(data: { [string | number]: any })
|
--[[
|
||||||
local target = {}
|
Unregisters from server state updates for the given keys. The signals
|
||||||
|
will retain their current values, but will no longer receive updates
|
||||||
|
from the server.
|
||||||
|
]]
|
||||||
|
local function removeSignals(...: string)
|
||||||
|
for index = 1, select("#", ...) do
|
||||||
|
local key = select(index, ...)
|
||||||
|
trackedSignals[key] = nil
|
||||||
|
trackedReactives[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
for key, atom in next, atoms do
|
--[[
|
||||||
target[key] = atom()
|
Unsubscribes from all server state updates.
|
||||||
end
|
]]
|
||||||
|
local function removeAllSignals()
|
||||||
|
table.clear(trackedSignals)
|
||||||
|
table.clear(trackedReactives)
|
||||||
|
end
|
||||||
|
|
||||||
target = patch.apply(target, data)
|
local function applyInitPayload(data: { [string]: any })
|
||||||
|
for key, value in data do
|
||||||
for key, atom in next, atoms do
|
local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key]
|
||||||
atom(target[key])
|
if trackedSignal then
|
||||||
|
trackedSignal(value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function self:sync(...)
|
local function applyPatchPayload(data: { [string]: any })
|
||||||
for index = 1, select("#", ...) do
|
for key, diffs in data do
|
||||||
local payload: SyncPayload = select(index, ...)
|
local trackedSignal = trackedSignals[key]
|
||||||
|
local trackedReactive = trackedReactives[key]
|
||||||
|
|
||||||
Charm.batch(function()
|
if trackedSignal then
|
||||||
if payload.type == "init" then
|
trackedSignal(function(value)
|
||||||
hydrate(payload.data)
|
return patch.apply(value, diffs)
|
||||||
elseif not ignoreUnhydrated or hydrated then
|
end)
|
||||||
apply(payload.data)
|
elseif trackedReactive then
|
||||||
end
|
trackedReactive(function(value)
|
||||||
|
patch.applyMutable(value, diffs)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return self
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return client
|
--[[
|
||||||
|
Merges the server's state patches into the client's state, either
|
||||||
|
initializing it with the full state or merging a partial patch.
|
||||||
|
|
||||||
|
@param payloads The state updates received from the server.
|
||||||
|
]]
|
||||||
|
local function patch(payloads: { SyncPayload })
|
||||||
|
for _, payload in payloads do
|
||||||
|
if payload.type == "init" then
|
||||||
|
Charm.batch(applyInitPayload, payload.data)
|
||||||
|
elseif payload.type == "patch" then
|
||||||
|
Charm.batch(applyPatchPayload, payload.data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
addSignals = addSignals,
|
||||||
|
removeSignals = removeSignals,
|
||||||
|
removeAllSignals = removeAllSignals,
|
||||||
|
patch = patch,
|
||||||
|
}
|
||||||
|
|
|
||||||
28
packages/charm-sync/src/config.luau
Normal file
28
packages/charm-sync/src/config.luau
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
return {
|
||||||
|
--[[
|
||||||
|
The interval at which to send patches to the client, in seconds.
|
||||||
|
Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||||
|
negative value to disable automatic syncing.
|
||||||
|
]]
|
||||||
|
interval = 0,
|
||||||
|
--[[
|
||||||
|
Whether the history of state changes since the client's last update
|
||||||
|
should be preserved. This is useful for values that change multiple
|
||||||
|
times per frame, where each individual change is important. Defaults
|
||||||
|
to `false`.
|
||||||
|
]]
|
||||||
|
preserveHistory = false,
|
||||||
|
--[[
|
||||||
|
When `true`, Charm will apply validation and serialize unsafe arrays
|
||||||
|
to address remote event argument limitations. Defaults to `true`.
|
||||||
|
This option should be disabled if your network library uses a custom
|
||||||
|
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||||
|
]]
|
||||||
|
fixArrays = true,
|
||||||
|
--[[
|
||||||
|
When `true`, synced state containing unsafe sparse arrays or mixed
|
||||||
|
tables will throw an error. Only checked in strict mode and if
|
||||||
|
`fixArrays` is enabled. Defaults to `true`.
|
||||||
|
]]
|
||||||
|
validatePatches = true,
|
||||||
|
}
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type AtomMap = types.AtomMap
|
|
||||||
|
|
||||||
type NestedAtomMap = {
|
|
||||||
[string]: NestedAtomMap | () -> (),
|
|
||||||
}
|
|
||||||
|
|
||||||
local function flatten(atoms: NestedAtomMap): AtomMap
|
|
||||||
local result: AtomMap = {}
|
|
||||||
|
|
||||||
local function visit(node: NestedAtomMap, path: string)
|
|
||||||
for key, value in node do
|
|
||||||
local location = if path == "" then key else path .. "/" .. key
|
|
||||||
|
|
||||||
if type(value) == "table" then
|
|
||||||
visit(value, location)
|
|
||||||
else
|
|
||||||
result[location] = value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
visit(atoms, "")
|
|
||||||
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
return flatten
|
|
||||||
455
packages/charm-sync/src/index.d.ts
vendored
455
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1,214 +1,289 @@
|
||||||
import { Atom, Selector } from "@rbxts/charm";
|
import { Atom, Setter } from "@rbxts/charm";
|
||||||
|
|
||||||
export = CharmSync;
|
|
||||||
export as namespace CharmSync;
|
|
||||||
|
|
||||||
type Cleanup = () => void;
|
|
||||||
|
|
||||||
type Key = string | number | symbol;
|
type Key = string | number | symbol;
|
||||||
|
|
||||||
declare namespace CharmSync {
|
/**
|
||||||
type AtomMap = Record<string, Atom<any>>;
|
* Represents the removal of a value from the state.
|
||||||
|
*/
|
||||||
|
export interface None {
|
||||||
|
readonly __none: "__none";
|
||||||
|
}
|
||||||
|
|
||||||
type SelectorMap = Record<string, Selector<any>>;
|
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||||
|
|
||||||
|
type ReactiveObject = { [key: Key]: any };
|
||||||
|
|
||||||
|
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
|
||||||
|
|
||||||
|
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers the type of the return values produced by a map of functions.
|
||||||
|
*/
|
||||||
|
export type StateOfMap<T> = {
|
||||||
|
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataTypes = {
|
||||||
|
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataType = DataTypes[keyof DataTypes];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A partial patch that can be applied to the state to update it. Represents
|
||||||
|
* the difference between the current state and the next state.
|
||||||
|
*
|
||||||
|
* If a value was removed, it is replaced with `None`. This can be checked
|
||||||
|
* using the `isNone` function.
|
||||||
|
*/
|
||||||
|
// TODO: Optimize type, this looks overly complex
|
||||||
|
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
|
||||||
|
| ReadonlyMap<infer K, infer V>
|
||||||
|
| Map<infer K, infer V>
|
||||||
|
? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
|
||||||
|
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||||
|
? ReadonlyMap<T, true | None>
|
||||||
|
: State extends readonly (infer T)[]
|
||||||
|
? FixArrays extends true
|
||||||
|
? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
|
||||||
|
: readonly (SyncPatch<T, FixArrays> | None | undefined)[]
|
||||||
|
: State extends DataType
|
||||||
|
? State
|
||||||
|
: State extends object
|
||||||
|
? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
|
||||||
|
: State;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A payload that can be sent from the server to the client to synchronize
|
||||||
|
* state between the two.
|
||||||
|
*/
|
||||||
|
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
|
||||||
|
| { type: "init"; data: StateOfMap<Getters> }
|
||||||
|
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global configuration options that affect the behavior of Charm Sync.
|
||||||
|
*/
|
||||||
|
export const config: {
|
||||||
/**
|
/**
|
||||||
* @deprecated Use `SelectorMap` instead.
|
* 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
|
||||||
type MoleculeMap = SelectorMap;
|
* negative value to disable automatic syncing.
|
||||||
|
|
||||||
/**
|
|
||||||
* Infers the type of the return values produced by a map of functions.
|
|
||||||
*/
|
|
||||||
type StateOfMap<T> = {
|
|
||||||
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the removal of a value from the state.
|
|
||||||
*/
|
|
||||||
interface None {
|
|
||||||
readonly __none: "__none";
|
|
||||||
}
|
|
||||||
|
|
||||||
type MaybeNone<T> = undefined extends T ? None : never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
|
||||||
* applies them to the local state.
|
|
||||||
*
|
|
||||||
* @client
|
|
||||||
* @param options The atoms to synchronize with the server.
|
|
||||||
* @returns A `ClientSyncer` object.
|
|
||||||
*/
|
|
||||||
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
|
||||||
* hydrates the client's state.
|
|
||||||
*
|
*
|
||||||
* @server
|
* @server
|
||||||
* @param options The atoms to synchronize with the client.
|
|
||||||
* @returns A `ServerSyncer` object.
|
|
||||||
*/
|
*/
|
||||||
function server<Selectors extends SelectorMap, Serialize extends boolean = true>(
|
interval: number;
|
||||||
options: ServerOptions<Selectors, Serialize>,
|
|
||||||
): ServerSyncer<Selectors, Serialize>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
* Whether the history of state changes since the client's last update
|
||||||
* removed from the state when the patch is applied.
|
* should be preserved. This is useful for values that change multiple
|
||||||
|
* times per frame, where each individual change is important. Defaults
|
||||||
|
* to `false`.
|
||||||
*
|
*
|
||||||
* @param value The value to check.
|
* @server
|
||||||
* @returns `true` if the value is `None`, otherwise `false`.
|
|
||||||
*/
|
*/
|
||||||
function isNone(value: unknown): value is None;
|
preserveHistory: boolean;
|
||||||
|
|
||||||
interface NestedAtomMap {
|
|
||||||
readonly [key: string]: NestedAtomMap | Atom<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
|
|
||||||
? Name
|
|
||||||
: Name extends string
|
|
||||||
? Path extends string
|
|
||||||
? `${Path}/${Name}`
|
|
||||||
: never
|
|
||||||
: never;
|
|
||||||
|
|
||||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
||||||
|
|
||||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
|
||||||
|
|
||||||
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
|
|
||||||
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
|
|
||||||
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
|
|
||||||
: Atoms[Name] extends NestedAtomMap
|
|
||||||
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
|
|
||||||
: never;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flattens a nested atom map into a single object with slash-separated
|
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||||
* keys. Useful for recursively collecting atoms returned by modules.
|
* to address remote event argument limitations. Defaults to `true`.
|
||||||
|
* This option should be disabled if your network library uses a custom
|
||||||
|
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||||
*
|
*
|
||||||
* @param atoms The nested atom map to flatten.
|
* @server
|
||||||
* @return A flattened atom map.
|
|
||||||
*/
|
*/
|
||||||
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
fixArrays: boolean;
|
||||||
|
|
||||||
type DataTypes = {
|
|
||||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type that should not be made partial in patches.
|
* When `true`, synced state containing unsafe sparse arrays or mixed
|
||||||
*/
|
* tables will throw an error. Only checked in strict mode and if
|
||||||
type DataType = DataTypes[keyof DataTypes];
|
* `fixArrays` is enabled. Defaults to `true`.
|
||||||
|
|
||||||
/**
|
|
||||||
* A partial patch that can be applied to the state to update it. Represents
|
|
||||||
* the difference between the current state and the next state.
|
|
||||||
*
|
*
|
||||||
* If a value was removed, it is replaced with `None`. This can be checked
|
* @server
|
||||||
* using the `sync.isNone` function.
|
|
||||||
*/
|
*/
|
||||||
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
validatePatches: boolean;
|
||||||
| ReadonlyMap<infer K, infer V>
|
};
|
||||||
| Map<infer K, infer V>
|
|
||||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
/**
|
||||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
* @client
|
||||||
? ReadonlyMap<T, true | None>
|
*/
|
||||||
: State extends readonly (infer T)[]
|
export namespace client {
|
||||||
? Serialize extends true
|
/**
|
||||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
* Registers a map of setters to the state with the corresponding keys on the
|
||||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
* server. When the server sends updates, these setters will be called with
|
||||||
: State extends DataType
|
* the new values.
|
||||||
? State
|
*
|
||||||
: State extends object
|
* Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
* 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.
|
||||||
|
*/
|
||||||
|
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A payload that can be sent from the server to the client to synchronize
|
* Unregisters from server state updates for the given keys. The signals
|
||||||
* state between the two.
|
* will retain their current values, but will no longer receive updates
|
||||||
|
* from the server.
|
||||||
*/
|
*/
|
||||||
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> =
|
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
|
||||||
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
|
...keys: (keyof Signals)[]
|
||||||
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
): void;
|
||||||
|
|
||||||
interface ClientOptions<Atoms extends AtomMap> {
|
/**
|
||||||
/**
|
* Unsubscribes from all server state updates.
|
||||||
* The atoms to synchronize with the server.
|
*/
|
||||||
*/
|
export function removeAllSignals(): void;
|
||||||
atoms: Atoms;
|
|
||||||
/**
|
|
||||||
* Whether to ignore patches sent before the client has been hydrated.
|
|
||||||
* Defaults to `true`.
|
|
||||||
*/
|
|
||||||
ignoreUnhydrated?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> {
|
/**
|
||||||
/**
|
* Merges the server's state patches into the client's state, either
|
||||||
* The atoms to synchronize with the client.
|
* initializing it with the full state or merging a partial patch.
|
||||||
*/
|
*
|
||||||
atoms: Selectors;
|
* @param payloads The state updates received from the server.
|
||||||
/**
|
*/
|
||||||
* The interval at which to send patches to the client, in seconds.
|
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
payloads: SyncPayload<Getters, FixArrays>[],
|
||||||
* negative value to disable automatic syncing.
|
): void;
|
||||||
*/
|
|
||||||
interval?: number;
|
|
||||||
/**
|
|
||||||
* Whether the history of state changes since the client's last update
|
|
||||||
* should be preserved. This is useful for values that change multiple times
|
|
||||||
* per frame, where each individual change is important. Defaults to `false`.
|
|
||||||
*
|
|
||||||
* If `true`, the broadcaster will send a list of payloads to the client
|
|
||||||
* instead of a single payload. The client will apply each payload in order
|
|
||||||
* to reconstruct the state's changes over time.
|
|
||||||
*/
|
|
||||||
preserveHistory?: boolean;
|
|
||||||
/**
|
|
||||||
* When `true`, Charm will apply validation and serialize unsafe arrays
|
|
||||||
* to address remote event argument limitations. Defaults to `true`.
|
|
||||||
*
|
|
||||||
* This option should be disabled if your network library uses a custom
|
|
||||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
|
||||||
*/
|
|
||||||
autoSerialize?: Serialize;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClientSyncer<Atoms extends AtomMap> {
|
|
||||||
/**
|
|
||||||
* Applies a patch or initializes the state of the atoms with the given
|
|
||||||
* payload from the server.
|
|
||||||
*
|
|
||||||
* @param payloads The patches or hydration payloads to apply.
|
|
||||||
*/
|
|
||||||
sync(...payloads: SyncPayload<Atoms, boolean>[]): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
|
|
||||||
/**
|
|
||||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
|
||||||
* the client whenever the state changes. When a change occurs, the `callback`
|
|
||||||
* is called with the player and the payloads to send.
|
|
||||||
*
|
|
||||||
* Note that a `payload` object should not be mutated. If you need to modify
|
|
||||||
* a payload, apply the changes to a copy of the object.
|
|
||||||
*
|
|
||||||
* @param callback The function to call when the state changes.
|
|
||||||
* @returns A cleanup function that unsubscribes all listeners.
|
|
||||||
*/
|
|
||||||
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors, Serialize>[]) => void): Cleanup;
|
|
||||||
/**
|
|
||||||
* Hydrates the client's state with the server's state. This should be
|
|
||||||
* called when a player joins the game and requires the server's state.
|
|
||||||
*
|
|
||||||
* @param player The player to hydrate.
|
|
||||||
*/
|
|
||||||
hydrate(player: Player): void;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @server
|
||||||
|
*/
|
||||||
|
export namespace server {
|
||||||
|
/**
|
||||||
|
* Subscribes a client to the given signals. When an update occurs, the client
|
||||||
|
* will receive a partial state patch to merge into their local state. May be
|
||||||
|
* called multiple times to subscribe to additional keys.
|
||||||
|
*
|
||||||
|
* Atoms, computed signals, and signal getter functions are allowed. Note
|
||||||
|
* that `client.addSignals` still requires some way to set the state.
|
||||||
|
*
|
||||||
|
* @param client The client receiving state updates.
|
||||||
|
* @param signals A map of getter functions to sync with the client.
|
||||||
|
*/
|
||||||
|
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||||
|
client: Player,
|
||||||
|
signals: Signals,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||||
|
* from specific keys, use `removeSignalsFromClient` instead.
|
||||||
|
*
|
||||||
|
* @param client The client receiving state updates.
|
||||||
|
*/
|
||||||
|
export function removeClient(client: Player): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribes a client from receiving updates for the given keys.
|
||||||
|
*
|
||||||
|
* @param client The client receiving state updates.
|
||||||
|
* @param keys The keys of the state to unsubscribe from.
|
||||||
|
*/
|
||||||
|
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||||
|
client: Player,
|
||||||
|
...keys: (keyof Signals)[]
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the callback to send state updates to clients that are subscribed
|
||||||
|
* to state changes. Starts automatically sending updates at the interval
|
||||||
|
* specified in `config.interval`.
|
||||||
|
*
|
||||||
|
* @param onSync Called when sending patches to a client.
|
||||||
|
*/
|
||||||
|
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||||
|
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops syncing state updates to clients at the automatic interval.
|
||||||
|
* Flushing can still be performed manually via `flush`.
|
||||||
|
*/
|
||||||
|
export function disconnect(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately sends pending updates to all clients. Normally, updates are
|
||||||
|
* sent at the interval specified in `config.interval`, but this function
|
||||||
|
* can be used to force an immediate sync.
|
||||||
|
*/
|
||||||
|
export function flush(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribes all clients, stops tracking all state, and disconnects
|
||||||
|
* from the automatic sync interval.
|
||||||
|
*/
|
||||||
|
export function reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace patch {
|
||||||
|
/**
|
||||||
|
* Converts `nil` values to the special `None` symbol. This is useful when
|
||||||
|
* preparing data for serialization, since `nil` can represent both the
|
||||||
|
* absence of a value and the removal of a value in patches.
|
||||||
|
*/
|
||||||
|
export function nilToNone<T>(value: T | undefined): T | None;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two states and returns a patch that can be applied to the
|
||||||
|
* current state to produce the next state.
|
||||||
|
*
|
||||||
|
* @param currentState The current state.
|
||||||
|
* @param nextState The next state.
|
||||||
|
* @returns A patch that can be applied to the current state.
|
||||||
|
*/
|
||||||
|
export function diff<State, FixArrays extends boolean = true>(
|
||||||
|
currentState: State,
|
||||||
|
nextState: State,
|
||||||
|
): SyncPatch<State, FixArrays>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a patch to a state and returns the resulting value.
|
||||||
|
*
|
||||||
|
* @param currentState The current state.
|
||||||
|
* @param statePatch The patches to apply.
|
||||||
|
* @returns The new state with the patch applied.
|
||||||
|
*/
|
||||||
|
export function apply<State, FixArrays extends boolean = true>(
|
||||||
|
currentState: State,
|
||||||
|
statePatch: SyncPatch<State, FixArrays>,
|
||||||
|
): State;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeepFlattenImpl<T, Prefix extends string = ""> = {
|
||||||
|
[K in keyof T]: T[K] extends DataType | Callback
|
||||||
|
? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
|
||||||
|
: T[K] extends object
|
||||||
|
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
|
||||||
|
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
|
||||||
|
}[keyof T];
|
||||||
|
|
||||||
|
type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens a nested table into a single table with slash-separated keys.
|
||||||
|
* Useful for recursively collecting atoms returned by modules.
|
||||||
|
*
|
||||||
|
* @param input A nested table.
|
||||||
|
* @return A flattened table with slash-separated keys.
|
||||||
|
*/
|
||||||
|
export function flatten<T>(input: T): DeepFlatten<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||||
|
* removed from the state when the patch is applied.
|
||||||
|
*
|
||||||
|
* @param value The value to check.
|
||||||
|
* @returns `true` if the value is `None`, otherwise `false`.
|
||||||
|
*/
|
||||||
|
export function isNone(value: unknown): value is None;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a signal getter and setter into an atom. This is useful for
|
||||||
|
* syncing client signals with the server, as the sync system requires both
|
||||||
|
* the getter and setter to update state.
|
||||||
|
*
|
||||||
|
* @param getter A function that returns the current value of the signal.
|
||||||
|
* @param setter A function that sets the value of the signal.
|
||||||
|
* @returns An atom that can either get or set the signal's value.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
|
||||||
|
*/
|
||||||
|
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
local client = require(script.client)
|
local client = require("@self/client")
|
||||||
local flatten = require(script.flatten)
|
local config = require("@self/config")
|
||||||
local patch = require(script.patch)
|
local patch = require("@self/patch")
|
||||||
local server = require(script.server)
|
local server = require("@self/server")
|
||||||
local types = require(script.types)
|
local types = require("@self/types")
|
||||||
|
local utils = require("@self/utils")
|
||||||
|
|
||||||
|
export type None = types.None
|
||||||
export type SyncPayload = types.SyncPayload
|
export type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
--[=[
|
|
||||||
Synchronizes state between the client and server. The server sends patches
|
|
||||||
to the client, which applies them to its local state.
|
|
||||||
]=]
|
|
||||||
return {
|
return {
|
||||||
|
config = config,
|
||||||
client = client,
|
client = client,
|
||||||
server = server,
|
server = server,
|
||||||
flatten = flatten,
|
patch = patch,
|
||||||
isNone = patch.isNone,
|
isNone = patch.isNone,
|
||||||
|
flatten = utils.flatten,
|
||||||
|
signalToAtom = utils.signalToAtom,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
local RunService = game:GetService("RunService")
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Schedules a function to be called at a fixed interval of `seconds`.
|
|
||||||
|
|
||||||
@param callback The function to call.
|
|
||||||
@param seconds The interval in seconds.
|
|
||||||
@return A function that cancels the interval.
|
|
||||||
]=]
|
|
||||||
local function interval(callback: () -> (), seconds: number): () -> ()
|
|
||||||
if seconds < 0 then
|
|
||||||
return function() end
|
|
||||||
end
|
|
||||||
|
|
||||||
local clock = 0
|
|
||||||
|
|
||||||
local connection = RunService.Heartbeat:Connect(function(delta)
|
|
||||||
clock += delta
|
|
||||||
if clock >= seconds then
|
|
||||||
clock = 0
|
|
||||||
callback()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
connection:Disconnect()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return interval
|
|
||||||
|
|
@ -1,27 +1,44 @@
|
||||||
local validate = require(script.Parent.validate)
|
local Charm = require("../Charm")
|
||||||
|
local config = require("./config")
|
||||||
|
local types = require("./types")
|
||||||
|
local validate = require("./validate")
|
||||||
|
|
||||||
local __DEV__ = _G.__DEV__
|
local flags = Charm.flags
|
||||||
|
|
||||||
--[=[
|
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" }
|
|
||||||
|
|
||||||
|
local None: None = { __none = "__none" }
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||||
|
removed from the state when the patch is applied.
|
||||||
|
|
||||||
|
@param value The value to check.
|
||||||
|
@return `true` if the value is `None`, otherwise `false`.
|
||||||
|
]]
|
||||||
local function isNone(value: any): boolean
|
local function isNone(value: any): boolean
|
||||||
return type(value) == "table" and value.__none == "__none"
|
return type(value) == "table" and value.__none == "__none"
|
||||||
end
|
end
|
||||||
|
|
||||||
--[=[
|
--[[
|
||||||
|
Converts `nil` values to the special `None` symbol. This is useful when
|
||||||
|
preparing data for serialization, since `nil` can represent both the absence
|
||||||
|
of a value and the removal of a value in patches.
|
||||||
|
]]
|
||||||
|
local function nilToNone<T>(value: T?): T | None
|
||||||
|
return if value == nil then None else value
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
JSON serialization can drop the last values of a sparse array. For example,
|
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 }): { [string | number]: any }
|
||||||
local maxn = table.maxn(object)
|
local maxn = table.maxn(object)
|
||||||
|
|
||||||
|
|
@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
|
||||||
|
|
||||||
local dictionary = {}
|
local dictionary = {}
|
||||||
|
|
||||||
for index, value in next, object do
|
for index, value in object do
|
||||||
dictionary[tostring(index)] = value
|
dictionary[tostring(index)] = value
|
||||||
end
|
end
|
||||||
|
|
||||||
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 previous
|
if oldState == newState then
|
||||||
state to produce the next state.
|
return nil
|
||||||
|
elseif newState == nil then
|
||||||
|
return None
|
||||||
|
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
|
||||||
|
return newState
|
||||||
|
end
|
||||||
|
|
||||||
@param prevState The previous state.
|
local diffs: { [any]: any } = table.clone(newState)
|
||||||
@param nextState The next state.
|
|
||||||
@param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
|
|
||||||
@returns A patch that can be applied to the previous state.
|
|
||||||
]=]
|
|
||||||
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
|
|
||||||
serialize = serialize ~= false
|
|
||||||
|
|
||||||
local patches = table.clone(nextState)
|
for key, oldValue in oldState do
|
||||||
|
diffs[key] = diff(oldValue, newState[key])
|
||||||
|
end
|
||||||
|
|
||||||
for key, previous in next, prevState do
|
if next(diffs) == nil then
|
||||||
local current = nextState[key]
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
if previous == current then
|
if config.fixArrays then
|
||||||
patches[key] = nil
|
-- Convert sparse array patches that could be misserialized
|
||||||
elseif current == nil then
|
if oldState[1] ~= nil or newState[1] ~= nil then
|
||||||
patches[key] = None
|
diffs = stringifySparseArray(diffs)
|
||||||
elseif type(previous) == "table" and type(current) == "table" then
|
end
|
||||||
patches[key] = diff(previous, current, serialize)
|
if flags.strict and config.validatePatches then
|
||||||
|
validate(diffs)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Coerce sparse array patches into dictionaries
|
return diffs
|
||||||
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
|
|
||||||
patches = stringifySparseArray(patches)
|
|
||||||
end
|
|
||||||
|
|
||||||
if serialize and __DEV__ then
|
|
||||||
for key, value in next, patches do
|
|
||||||
validate(value, key)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return patches
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--[=[
|
local function apply(oldState: any, diffs: any): any
|
||||||
Applies a patch to a state and returns the next state.
|
if isNone(diffs) then
|
||||||
|
|
||||||
@param state The current state.
|
|
||||||
@param patches The patches to apply.
|
|
||||||
@returns The next state.
|
|
||||||
]=]
|
|
||||||
local function apply(state: any, patches: any): any
|
|
||||||
if type(patches) == "table" and patches.__none == "__none" then
|
|
||||||
return nil
|
return nil
|
||||||
elseif type(state) ~= "table" or type(patches) ~= "table" then
|
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
|
||||||
return patches
|
return diffs
|
||||||
end
|
end
|
||||||
|
|
||||||
local nextState = table.clone(state)
|
local newState = table.clone(oldState)
|
||||||
local stateIsArray = state[1] ~= nil
|
local stateIsArray = oldState[1] ~= nil
|
||||||
|
|
||||||
for key, patch in next, patches do
|
for key, value in diffs do
|
||||||
-- Diff-checking an array produces a sparse array, which will not be
|
-- 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], patch)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return nextState
|
return newState
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyMutable(targetState: any, sourceState: any): any
|
||||||
|
if type(targetState) == "table" and type(sourceState) == "table" then
|
||||||
|
local targetIsArray = targetState[1] ~= nil
|
||||||
|
|
||||||
|
for key, value in sourceState do
|
||||||
|
if targetIsArray and type(key) == "string" then
|
||||||
|
key = tonumber(key) or key
|
||||||
|
end
|
||||||
|
targetState[key] = applyMutable(targetState[key], value)
|
||||||
|
end
|
||||||
|
|
||||||
|
return targetState
|
||||||
|
elseif isNone(sourceState) then
|
||||||
|
return nil
|
||||||
|
else
|
||||||
|
return sourceState
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isNone = isNone,
|
isNone = isNone,
|
||||||
|
nilToNone = nilToNone,
|
||||||
diff = diff,
|
diff = diff,
|
||||||
apply = apply,
|
apply = apply,
|
||||||
|
applyMutable = applyMutable,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,267 +1,413 @@
|
||||||
local Players = game:GetService("Players")
|
local Charm = require("../Charm")
|
||||||
|
local config = require("./config")
|
||||||
|
local patch = require("./patch")
|
||||||
|
local types = require("./types")
|
||||||
|
local utils = require("./utils")
|
||||||
|
|
||||||
local Charm = require(script.Parent.Parent.Charm)
|
|
||||||
local interval = require(script.Parent.interval)
|
|
||||||
local patch = require(script.Parent.patch)
|
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type AtomMap = types.AtomMap
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
type ServerOptions = {
|
type SyncStatus = number
|
||||||
--[=[
|
|
||||||
The atoms to synchronize with the client.
|
|
||||||
]=]
|
|
||||||
atoms: AtomMap,
|
|
||||||
--[=[
|
|
||||||
The interval at which to send patches to the client, in seconds.
|
|
||||||
Defaults to `0` (patches are sent up to once per frame). Set to a
|
|
||||||
negative value to disable automatic syncing.
|
|
||||||
]=]
|
|
||||||
interval: number?,
|
|
||||||
--[=[
|
|
||||||
Whether the history of state changes since the client's last update
|
|
||||||
should be preserved. This is useful for values that change multiple times
|
|
||||||
per frame, where each individual change is important. Defaults to `false`.
|
|
||||||
]=]
|
|
||||||
preserveHistory: boolean?,
|
|
||||||
--[=[
|
|
||||||
When `true`, Charm will apply validation and serialize unsafe arrays
|
|
||||||
to address remote event argument limitations. Defaults to `true`.
|
|
||||||
|
|
||||||
This option should be disabled if your network library uses a custom
|
type ClientKeys = { [string]: SyncStatus }
|
||||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
|
||||||
]=]
|
type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
|
||||||
autoSerialize: boolean?,
|
|
||||||
|
type TrackedState<T = any> = {
|
||||||
|
target: any,
|
||||||
|
currentValue: T,
|
||||||
|
lastSyncedValue: T,
|
||||||
|
dispose: () -> (),
|
||||||
|
isReactive: boolean?,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSyncer = {
|
type StateUpdate = {
|
||||||
--[=[
|
key: string,
|
||||||
Sets up a subscription to each atom that schedules a patch to be sent to
|
newValue: any,
|
||||||
the client whenever the state changes. When a change occurs, the `callback`
|
oldValue: any,
|
||||||
is called with the player and the payload to send.
|
|
||||||
|
|
||||||
Note that the `payload` object should not be mutated. If you need to
|
|
||||||
modify the payload, apply the changes to a copy of the object.
|
|
||||||
|
|
||||||
@param callback The function to call when the state changes.
|
|
||||||
@return A cleanup function that unsubscribes all listeners.
|
|
||||||
]=]
|
|
||||||
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
|
|
||||||
--[=[
|
|
||||||
Hydrates the client's state with the server's state. This should be
|
|
||||||
called when a player joins the game and requires the server's state.
|
|
||||||
|
|
||||||
@param player The player to hydrate.
|
|
||||||
]=]
|
|
||||||
hydrate: (self: ServerSyncer, player: Player) -> (),
|
|
||||||
--[=[
|
|
||||||
@deprecated For internal use only.
|
|
||||||
]=]
|
|
||||||
_sendPatch: (self: ServerSyncer, player: Player) -> (),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
--[=[
|
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||||
Creates a `ServerSyncer` object that sends patches to the client and
|
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||||
hydrates the client's state.
|
|
||||||
|
|
||||||
@param options The atoms to synchronize with the client.
|
|
||||||
@return A `ServerSyncer` object.
|
|
||||||
]=]
|
|
||||||
local function server(options: ServerOptions): ServerSyncer
|
|
||||||
local atoms = options.atoms
|
|
||||||
local autoSerialize = options.autoSerialize ~= false
|
|
||||||
local preserveHistory = options.preserveHistory
|
|
||||||
local syncRate = options.interval or 0
|
|
||||||
|
|
||||||
local self = {} :: ServerSyncer
|
local trackedStates: { [string]: TrackedState } = {}
|
||||||
local sync: (player: Player, payload: SyncPayload) -> ()
|
local globalUpdates: { StateUpdate } = {}
|
||||||
|
local clients: { [any]: ClientKeys } = {}
|
||||||
|
|
||||||
local function createSnapshot()
|
local connection: RBXScriptConnection?
|
||||||
local snapshot = {}
|
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
||||||
|
|
||||||
for key, atom in next, atoms do
|
local function trackSignal<T>(key: string, getter: () -> T)
|
||||||
snapshot[key] = atom()
|
if trackedStates[key] then
|
||||||
|
-- Ignore duplicates, but warn against name collisions
|
||||||
|
if trackedStates[key].target ~= getter then
|
||||||
|
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||||
end
|
end
|
||||||
|
return
|
||||||
return snapshot
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Send the initial state to a player when they join the server.
|
local value = getter()
|
||||||
function self:hydrate(player)
|
local trackedState = {
|
||||||
assert(sync, "connect() must be called before hydrate()")
|
target = getter,
|
||||||
|
currentValue = value,
|
||||||
|
lastSyncedValue = value,
|
||||||
|
}
|
||||||
|
|
||||||
sync(player, {
|
trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
|
||||||
type = "init",
|
-- When preserving history, we need to keep track of previous values
|
||||||
data = createSnapshot(),
|
-- so we can create granular patches for each change.
|
||||||
})
|
if config.preserveHistory then
|
||||||
|
table.insert(globalUpdates, {
|
||||||
|
key = key,
|
||||||
|
newValue = newValue,
|
||||||
|
oldValue = oldValue,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
trackedState.currentValue = newValue
|
||||||
|
end)
|
||||||
|
|
||||||
|
trackedStates[key] = trackedState
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trackReactive<T>(key: string, reactive: Reactive<T>)
|
||||||
|
if trackedStates[key] then
|
||||||
|
if trackedStates[key].target ~= reactive then
|
||||||
|
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||||
|
end
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- If the history of state changes should be preserved, keep a list of
|
local meta = getmetatable(reactive)
|
||||||
-- previous snapshots and diff-check each one from the last.
|
local snapshot = utils.deepCopy(meta.initialValue)
|
||||||
if preserveHistory then
|
local firstRun = true
|
||||||
local snapshots = { createSnapshot() }
|
|
||||||
local changed = false
|
|
||||||
|
|
||||||
function self:connect(callback)
|
local trackedState = {
|
||||||
local subscriptions = {}
|
target = reactive,
|
||||||
|
currentValue = meta.initialValue,
|
||||||
|
lastSyncedValue = snapshot,
|
||||||
|
isReactive = true,
|
||||||
|
}
|
||||||
|
|
||||||
sync = callback
|
trackedState.dispose = Charm.effect(function()
|
||||||
|
meta.track()
|
||||||
local function pushSnapshot(key: string | number, current: any, previous: any)
|
if not config.preserveHistory or firstRun then
|
||||||
local lastSnapshot = snapshots[#snapshots]
|
firstRun = false
|
||||||
local previousSnapshot = snapshots[#snapshots - 1]
|
else
|
||||||
|
local oldSnapshot = snapshot
|
||||||
-- Optimize snapshots by updating the most recent snapshot if the
|
snapshot = utils.deepCopy(meta.initialValue)
|
||||||
-- previous and current values are the same, since this allows us
|
table.insert(globalUpdates, {
|
||||||
-- to group multiple changes into a single snapshot.
|
key = key,
|
||||||
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
|
newValue = snapshot,
|
||||||
lastSnapshot[key] = current
|
oldValue = oldSnapshot,
|
||||||
else
|
})
|
||||||
local nextSnapshot = table.clone(lastSnapshot)
|
|
||||||
nextSnapshot[key] = current
|
|
||||||
table.insert(snapshots, nextSnapshot)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Populate the snapshot with the initial state of each atom.
|
|
||||||
-- Subscribe to each atom and update the state when it changes.
|
|
||||||
for key, atom in next, atoms do
|
|
||||||
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
|
|
||||||
pushSnapshot(key, current, previous)
|
|
||||||
changed = true
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local disconnect = interval(function()
|
|
||||||
if not changed then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local payloads: { SyncPayload } = {}
|
|
||||||
local lastSnapshot
|
|
||||||
|
|
||||||
for index, snapshot in next, snapshots do
|
|
||||||
lastSnapshot = snapshot
|
|
||||||
|
|
||||||
if index == 1 then
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(payloads, {
|
|
||||||
type = "patch",
|
|
||||||
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
snapshots = { lastSnapshot }
|
|
||||||
changed = false
|
|
||||||
|
|
||||||
for _, player in next, Players:GetPlayers() do
|
|
||||||
callback(player, unpack(payloads))
|
|
||||||
end
|
|
||||||
end, syncRate)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
disconnect()
|
|
||||||
|
|
||||||
for _, unsubscribe in next, subscriptions do
|
|
||||||
unsubscribe()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
function self:_sendPatch(player: Player)
|
trackedStates[key] = trackedState
|
||||||
assert(sync, "connect() must be called before _sendPatch()")
|
end
|
||||||
|
|
||||||
if not changed then
|
local function stopTracking(key: string)
|
||||||
return
|
local trackedState = trackedStates[key]
|
||||||
end
|
if trackedState then
|
||||||
|
trackedState.dispose()
|
||||||
|
trackedStates[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local payloads: { SyncPayload } = {}
|
local function purgeUntracked()
|
||||||
|
local active: { [string]: boolean } = {}
|
||||||
|
|
||||||
for index, snapshot in next, snapshots do
|
for _, clientKeys in clients do
|
||||||
if index == 1 then
|
for key in clientKeys do
|
||||||
continue
|
active[key] = true
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(payloads, {
|
|
||||||
type = "patch",
|
|
||||||
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
snapshots = { snapshots[#snapshots] }
|
|
||||||
changed = false
|
|
||||||
|
|
||||||
sync(player, unpack(payloads))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return self
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local previousSnapshot = createSnapshot()
|
for key in trackedStates do
|
||||||
local currentSnapshot = table.clone(previousSnapshot)
|
if not active[key] then
|
||||||
local changed = false
|
stopTracking(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function self:connect(callback)
|
local function diffGlobalUpdateBuffer(): { any }
|
||||||
local subscriptions = {}
|
local patchCount = #globalUpdates
|
||||||
|
local patches = table.create(patchCount)
|
||||||
|
|
||||||
sync = callback
|
for index, update in globalUpdates do
|
||||||
|
patches[index] = patch.diff(update.oldValue, update.newValue)
|
||||||
|
end
|
||||||
|
|
||||||
-- Subscribe to each atom and update the state when one changes.
|
return patches
|
||||||
for key, atom in next, atoms do
|
end
|
||||||
subscriptions[key] = Charm.subscribe(atom, function(state)
|
|
||||||
currentSnapshot[key] = state
|
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
|
||||||
changed = true
|
local clientKeys = clients[client]
|
||||||
end)
|
|
||||||
|
if not clientKeys then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local payloads: { SyncPayload } = {}
|
||||||
|
|
||||||
|
-- Iterate over all global updates and see if the client is interested in
|
||||||
|
-- any of them. If so, we create a patch payload for the update.
|
||||||
|
for index, update in globalUpdates do
|
||||||
|
local patchData = globalPatches[index]
|
||||||
|
|
||||||
|
if not patchData then
|
||||||
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
local disconnect = interval(function()
|
local key = update.key
|
||||||
if not changed then
|
local status = clientKeys[key]
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local payload: SyncPayload = {
|
if status == LISTENING_FOR_CHANGES then
|
||||||
|
table.insert(payloads, {
|
||||||
type = "patch",
|
type = "patch",
|
||||||
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
|
data = { [key] = patchData },
|
||||||
}
|
})
|
||||||
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
|
table.insert(payloads, {
|
||||||
|
type = "init",
|
||||||
|
data = { [key] = update.newValue },
|
||||||
|
})
|
||||||
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
previousSnapshot = table.clone(currentSnapshot)
|
-- If a pending signal was never updated, we still need to send its initial
|
||||||
changed = false
|
-- state. This can happen if a signal was registered after its last change.
|
||||||
|
local initData: { [string]: any } = {}
|
||||||
|
|
||||||
for _, player in next, Players:GetPlayers() do
|
for key, status in clientKeys do
|
||||||
callback(player, payload)
|
local trackedState = trackedStates[key]
|
||||||
end
|
|
||||||
end, syncRate)
|
|
||||||
|
|
||||||
return function()
|
if status == PENDING_INITIAL_STATE and trackedState then
|
||||||
disconnect()
|
initData[key] = trackedState.currentValue
|
||||||
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
for _, unsubscribe in next, subscriptions do
|
if next(initData) ~= nil then
|
||||||
unsubscribe()
|
table.insert(payloads, 1, { type = "init" :: "init", data = initData })
|
||||||
|
end
|
||||||
|
|
||||||
|
return payloads
|
||||||
|
end
|
||||||
|
|
||||||
|
local function diffGlobalState(): any
|
||||||
|
local currentGlobalState: { [string]: any } = {}
|
||||||
|
local previousGlobalState: { [string]: any } = {}
|
||||||
|
|
||||||
|
for key, trackedState in trackedStates do
|
||||||
|
local currentValue = if trackedState.isReactive
|
||||||
|
then utils.deepCopy(trackedState.currentValue)
|
||||||
|
else trackedState.currentValue
|
||||||
|
|
||||||
|
currentGlobalState[key] = currentValue
|
||||||
|
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||||
|
trackedState.lastSyncedValue = currentValue
|
||||||
|
end
|
||||||
|
|
||||||
|
return patch.diff(previousGlobalState, currentGlobalState)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
|
||||||
|
local clientKeys = clients[client]
|
||||||
|
|
||||||
|
if not clientKeys then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local payloads: { SyncPayload } = {}
|
||||||
|
local initData: { [string]: any } = {}
|
||||||
|
local patchData: { [string]: any } = {}
|
||||||
|
|
||||||
|
-- Create patches for initialized signals that changed, and add signals
|
||||||
|
-- that are still pending initial state to the init payload.
|
||||||
|
for key, status in clientKeys do
|
||||||
|
local trackedState = trackedStates[key]
|
||||||
|
|
||||||
|
if status == LISTENING_FOR_CHANGES then
|
||||||
|
patchData[key] = globalPatch[key]
|
||||||
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
|
initData[key] = trackedState.currentValue
|
||||||
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if next(initData) ~= nil then
|
||||||
|
table.insert(payloads, { type = "init", data = initData })
|
||||||
|
end
|
||||||
|
|
||||||
|
if next(patchData) ~= nil then
|
||||||
|
table.insert(payloads, { type = "patch", data = patchData })
|
||||||
|
end
|
||||||
|
|
||||||
|
return payloads
|
||||||
|
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]: any } | () -> any })
|
||||||
|
if not clients[client] then
|
||||||
|
clients[client] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local clientKeys = clients[client]
|
||||||
|
|
||||||
|
for key, signal in signals do
|
||||||
|
if type(signal) == "function" then
|
||||||
|
trackSignal(key, signal)
|
||||||
|
else
|
||||||
|
trackReactive(key, signal :: any)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not clientKeys[key] then
|
||||||
|
-- Mark the signal as pending initial state so we send its full
|
||||||
|
-- value the next time we sync.
|
||||||
|
clientKeys[key] = PENDING_INITIAL_STATE
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||||
|
from specific keys, use `unsubscribeClientFrom` instead.
|
||||||
|
|
||||||
|
@param client The client receiving state updates.
|
||||||
|
]]
|
||||||
|
local function removeClient(client: any)
|
||||||
|
clients[client] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Unsubscribes a client from receiving updates for the given keys.
|
||||||
|
|
||||||
|
@param client The client receiving state updates.
|
||||||
|
@param keys The keys of the state to unsubscribe from.
|
||||||
|
]]
|
||||||
|
local function removeSignalsFromClient(client: any, ...: string)
|
||||||
|
local clientKeys = clients[client]
|
||||||
|
|
||||||
|
if not clientKeys then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for index = 1, select("#", ...) do
|
||||||
|
local key = select(index, ...)
|
||||||
|
clientKeys[key] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if next(clientKeys) == nil then
|
||||||
|
removeClient(client)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Immediately sends pending updates to all clients. Normally, updates are
|
||||||
|
sent at the interval specified in `config.interval`, but this function
|
||||||
|
can be used to force an immediate sync.
|
||||||
|
]]
|
||||||
|
local function flush()
|
||||||
|
if not syncCallback then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local granular = config.preserveHistory
|
||||||
|
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
|
||||||
|
|
||||||
|
for client in clients do
|
||||||
|
local payloads = if granular
|
||||||
|
then selectFromGlobalPatchList(client, globalPatch)
|
||||||
|
else selectFromGlobalPatch(client, globalPatch)
|
||||||
|
|
||||||
|
if payloads and payloads[1] then
|
||||||
|
if task then
|
||||||
|
task.spawn(syncCallback, client, payloads)
|
||||||
|
else
|
||||||
|
syncCallback(client, payloads)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function self:_sendPatch(player)
|
purgeUntracked()
|
||||||
assert(sync, "connect() must be called before _sendPatch()")
|
table.clear(globalUpdates)
|
||||||
|
end
|
||||||
|
|
||||||
if not changed then
|
--[[
|
||||||
|
Uses the callback to send state updates to clients that are subscribed
|
||||||
|
to state changes. Starts automatically sending updates at the interval
|
||||||
|
specified in `config.interval`.
|
||||||
|
|
||||||
|
@param onSync Called when sending patches to a client.
|
||||||
|
]]
|
||||||
|
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||||
|
if connection then
|
||||||
|
connection:Disconnect()
|
||||||
|
end
|
||||||
|
|
||||||
|
syncCallback = onSync
|
||||||
|
|
||||||
|
if not game then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local timer = 0
|
||||||
|
|
||||||
|
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
|
||||||
|
if config.interval < 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local payload: SyncPayload = {
|
timer += deltaTime
|
||||||
type = "patch",
|
|
||||||
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
|
|
||||||
}
|
|
||||||
|
|
||||||
previousSnapshot = table.clone(currentSnapshot)
|
if timer >= config.interval then
|
||||||
changed = false
|
timer = 0
|
||||||
|
debug.profilebegin("CharmSync")
|
||||||
sync(player, payload)
|
flush()
|
||||||
end
|
debug.profileend()
|
||||||
|
end
|
||||||
return self
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
return server
|
--[[
|
||||||
|
Stops syncing state updates to clients at the automatic interval.
|
||||||
|
Flushing can still be performed manually via `flush`.
|
||||||
|
]]
|
||||||
|
local function disconnect()
|
||||||
|
if connection then
|
||||||
|
connection:Disconnect()
|
||||||
|
connection = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Unsubscribes all clients, stops tracking all state, and disconnects
|
||||||
|
from the automatic sync interval.
|
||||||
|
]]
|
||||||
|
local function reset()
|
||||||
|
table.clear(trackedStates)
|
||||||
|
table.clear(clients)
|
||||||
|
table.clear(globalUpdates)
|
||||||
|
disconnect()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
addSignalsToClient = addSignalsToClient,
|
||||||
|
removeClient = removeClient,
|
||||||
|
removeSignalsFromClient = removeSignalsFromClient,
|
||||||
|
connect = connect,
|
||||||
|
disconnect = disconnect,
|
||||||
|
flush = flush,
|
||||||
|
reset = reset,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
local Charm = require(script.Parent.Parent.Charm)
|
--[[
|
||||||
type Atom<T> = Charm.Atom<T>
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
A payload that can be sent from the server to the client to synchronize
|
A payload that can be sent from the server to the client to synchronize
|
||||||
state between the two.
|
the states of the client's atoms with the server.
|
||||||
]=]
|
]]
|
||||||
export type SyncPayload = {
|
export type SyncPayload =
|
||||||
type: "init",
|
| {
|
||||||
data: { [string | number]: any },
|
type: "init",
|
||||||
} | {
|
data: { [string]: any },
|
||||||
type: "patch",
|
}
|
||||||
data: { [string | number]: any },
|
| {
|
||||||
}
|
type: "patch",
|
||||||
|
data: { [string]: any },
|
||||||
|
}
|
||||||
|
|
||||||
export type AtomMap = {
|
--[[
|
||||||
[string | number]: Atom<any>,
|
A special type that represents a removed value in a patch. This is used to
|
||||||
}
|
differentiate between a value that was set to `nil` and an unchanged value.
|
||||||
|
]]
|
||||||
|
export type None = { __none: "__none" }
|
||||||
|
|
||||||
return nil
|
return {}
|
||||||
|
|
|
||||||
72
packages/charm-sync/src/utils.luau
Normal file
72
packages/charm-sync/src/utils.luau
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
local Charm = require("../Charm")
|
||||||
|
|
||||||
|
type Atom<T> = Charm.Atom<T>
|
||||||
|
|
||||||
|
type NestedTable = {
|
||||||
|
[any]: NestedTable | any,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlattenedTable = {
|
||||||
|
[any]: any,
|
||||||
|
}
|
||||||
|
|
||||||
|
--[[
|
||||||
|
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
|
||||||
|
local location = if path == "" then key else path .. "/" .. key
|
||||||
|
|
||||||
|
if type(value) == "table" then
|
||||||
|
visit(value :: NestedTable, location)
|
||||||
|
else
|
||||||
|
result[location] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
visit(input, "")
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Converts a signal getter and setter into an atom. This is useful for
|
||||||
|
syncing client signals with the server, as the sync system requires both
|
||||||
|
the getter and setter to update state.
|
||||||
|
|
||||||
|
@param getter A function that returns the current value of the signal.
|
||||||
|
@param setter A function that sets the value of the signal.
|
||||||
|
@return An atom that can either get or set the signal's value.
|
||||||
|
]]
|
||||||
|
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
|
||||||
|
return function(...)
|
||||||
|
return if select("#", ...) == 0 then getter() else setter((...))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Returns a deep copy of the given table.
|
||||||
|
]]
|
||||||
|
local function deepCopy<T>(object: T): T
|
||||||
|
local copy: any = table.clone(object :: any)
|
||||||
|
for key, value in copy do
|
||||||
|
if type(value) == "table" then
|
||||||
|
copy[key] = deepCopy(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
flatten = flatten,
|
||||||
|
signalToAtom = signalToAtom,
|
||||||
|
deepCopy = deepCopy,
|
||||||
|
}
|
||||||
|
|
@ -1,67 +1,48 @@
|
||||||
local SAFE_KEYS = { string = true, number = true }
|
|
||||||
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
|
||||||
|
|
||||||
local function isTableUnsafe(object: { [any]: any })
|
local function isTableUnsafe(object: { [any]: any })
|
||||||
local keyType = nil
|
local key = next(object)
|
||||||
local objectSize = 0
|
|
||||||
|
|
||||||
-- All keys must have the same type
|
if key == nil then
|
||||||
for key in next, object do
|
return false
|
||||||
local currentType = type(key)
|
|
||||||
|
|
||||||
if not keyType and SAFE_KEYS[currentType] then
|
|
||||||
keyType = currentType
|
|
||||||
elseif keyType ~= currentType then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
objectSize += 1
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- If there are more keys than the length of the array, it's an array with
|
local keyType = type(key)
|
||||||
-- non-sequential keys.
|
local maxKey = 0
|
||||||
if objectSize > #object and keyType == "number" then
|
|
||||||
|
-- Keys must all be the same type
|
||||||
|
for key in object do
|
||||||
|
if type(key) ~= keyType then
|
||||||
|
return true
|
||||||
|
elseif keyType == "number" and key > maxKey then
|
||||||
|
maxKey = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Array keys must be sequential
|
||||||
|
if keyType == "number" and maxKey > #object then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
--[=[
|
--[[
|
||||||
Validates a value to ensure it can be synced over a remote event.
|
Emits a warning if the value is a table that cannot be synced via
|
||||||
|
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
||||||
|
with remote argument limitations, such as sparse arrays or mixed tables.
|
||||||
|
|
||||||
@param value The value to validate.
|
@param value The table value.
|
||||||
@param key The key of the value in the table.
|
@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, key: any)
|
if isTableUnsafe(value) then
|
||||||
local typeOfKey = type(key)
|
warn(
|
||||||
local typeOfValue = type(value)
|
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
|
||||||
|
.. "This can be for the following reasons:\n"
|
||||||
if not SAFE_KEYS[typeOfKey] then
|
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||||
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
|
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||||
elseif UNSAFE_VALUES[typeOfValue] then
|
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
|
||||||
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
|
value
|
||||||
elseif typeOfValue == "table" then
|
)
|
||||||
if getmetatable(value) ~= nil then
|
|
||||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
|
||||||
elseif isTableUnsafe(value) then
|
|
||||||
error(
|
|
||||||
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
|
|
||||||
.. "This can be for the following reasons:\n"
|
|
||||||
.. "1. The object is an array with non-sequential keys\n"
|
|
||||||
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
|
|
||||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if typeOfKey == "number" then
|
|
||||||
if key == math.huge or key == -math.huge then
|
|
||||||
error("Cannot sync infinity as key")
|
|
||||||
elseif key ~= math.floor(key) then
|
|
||||||
error("Cannot sync non-integer number as key")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/charm-sync"
|
name = "littensy/charm-sync"
|
||||||
version = "0.3.0"
|
version = "0.4.0-rc.2"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
|
exclude = [
|
||||||
|
"node_modules",
|
||||||
|
"src/__tests__",
|
||||||
|
"src/index.d.ts",
|
||||||
|
"package.json",
|
||||||
|
"Charm.luau",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
charm = "littensy/charm@^0.10"
|
Charm = "littensy/charm@0.11.0-rc.2"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "Charm",
|
"name": "charm",
|
||||||
"tree": {
|
"tree": {
|
||||||
"$path": "src"
|
"$path": "src"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
{
|
{
|
||||||
"name": "@rbxts/charm",
|
"name": "@rbxts/charm",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0-rc.2",
|
||||||
"description": "An atomic state management library for Roblox",
|
"description": "Atomic state management based on alien-signals",
|
||||||
"repository": "littensy/charm",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "src/init.luau",
|
"main": "src/init.luau",
|
||||||
"types": "src/index.d.ts",
|
"types": "src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"*.luau",
|
||||||
|
"!wally.toml",
|
||||||
|
"!wally.lock",
|
||||||
|
"!default.project.json",
|
||||||
|
"!**/__tests__"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cp ../../LICENSE.md . && 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 {}
|
||||||
391
packages/charm/src/__tests__/effect.spec.luau
Normal file
391
packages/charm/src/__tests__/effect.spec.luau
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local effectScope = Charm.effectScope
|
||||||
|
local endBatch = Charm.endBatch
|
||||||
|
local onCleanup = Charm.onCleanup
|
||||||
|
local recursive = Charm.recursive
|
||||||
|
local setActiveSub = Charm.setActiveSub
|
||||||
|
local signal = Charm.signal
|
||||||
|
local startBatch = Charm.startBatch
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should clear subscriptions when untracked by all subscribers", function()
|
||||||
|
local bRunTimes = 0
|
||||||
|
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b = computed(function()
|
||||||
|
bRunTimes += 1
|
||||||
|
return a() * 2
|
||||||
|
end)
|
||||||
|
local stopEffect = effect(function()
|
||||||
|
b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(bRunTimes == 1, "should run initially")
|
||||||
|
setA(2)
|
||||||
|
assert(bRunTimes == 2, "should run after change")
|
||||||
|
stopEffect()
|
||||||
|
setA(3)
|
||||||
|
assert(bRunTimes == 2, "should not run after stop")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not run untracked inner effect", function()
|
||||||
|
local a, setA = signal(3)
|
||||||
|
local b = computed(function()
|
||||||
|
return a() > 0
|
||||||
|
end)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
if b() then
|
||||||
|
effect(function()
|
||||||
|
if a() == 0 then
|
||||||
|
error("bad")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(2)
|
||||||
|
setA(1)
|
||||||
|
setA(0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run outer effect first", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(1)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
if a() > 0 then
|
||||||
|
effect(function()
|
||||||
|
b()
|
||||||
|
if a() == 0 then
|
||||||
|
error("bad")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
startBatch()
|
||||||
|
setB(0)
|
||||||
|
setA(0)
|
||||||
|
endBatch()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not trigger inner effect when resolve maybe dirty", function()
|
||||||
|
local a, setA = signal(0)
|
||||||
|
local b = computed(function()
|
||||||
|
return a() % 2
|
||||||
|
end)
|
||||||
|
|
||||||
|
local innerTriggerTimes = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
effect(function()
|
||||||
|
b()
|
||||||
|
innerTriggerTimes += 1
|
||||||
|
if innerTriggerTimes >= 2 then
|
||||||
|
error("bad")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(2)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should notify inner effects in the same order as non-inner effects", function()
|
||||||
|
local getA, setA = signal(0)
|
||||||
|
local getB, setB = signal(0)
|
||||||
|
local c = computed(function()
|
||||||
|
return getA() - getB()
|
||||||
|
end)
|
||||||
|
local order1 = {}
|
||||||
|
local order2 = {}
|
||||||
|
local order3 = {}
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
table.insert(order1, "effect1")
|
||||||
|
getA()
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
table.insert(order1, "effect2")
|
||||||
|
getA()
|
||||||
|
getB()
|
||||||
|
end)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
c()
|
||||||
|
effect(function()
|
||||||
|
table.insert(order2, "effect1")
|
||||||
|
getA()
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
table.insert(order2, "effect2")
|
||||||
|
getA()
|
||||||
|
getB()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
effectScope(function()
|
||||||
|
effect(function()
|
||||||
|
table.insert(order3, "effect1")
|
||||||
|
getA()
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
table.insert(order3, "effect2")
|
||||||
|
getA()
|
||||||
|
getB()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
table.clear(order1)
|
||||||
|
table.clear(order2)
|
||||||
|
table.clear(order3)
|
||||||
|
|
||||||
|
startBatch()
|
||||||
|
setB(1)
|
||||||
|
setA(1)
|
||||||
|
endBatch()
|
||||||
|
|
||||||
|
assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
|
||||||
|
assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
|
||||||
|
assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should custom effect support batch", function()
|
||||||
|
local function batchEffect(fn: () -> ())
|
||||||
|
return effect(function()
|
||||||
|
startBatch()
|
||||||
|
local success, result: unknown = pcall(fn)
|
||||||
|
endBatch()
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local logs: { string } = {}
|
||||||
|
local a = signal(0)
|
||||||
|
local b, setB = signal(0)
|
||||||
|
|
||||||
|
local aa = computed(function()
|
||||||
|
table.insert(logs, "aa-0")
|
||||||
|
if a() == 0 then
|
||||||
|
setB(1)
|
||||||
|
end
|
||||||
|
table.insert(logs, "aa-1")
|
||||||
|
return nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
local bb = computed(function()
|
||||||
|
table.insert(logs, "bb")
|
||||||
|
return b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
batchEffect(function()
|
||||||
|
bb()
|
||||||
|
end)
|
||||||
|
batchEffect(function()
|
||||||
|
aa()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should duplicate subscribers do not affect the notify order", function()
|
||||||
|
local src1, setSrc1 = signal(0)
|
||||||
|
local src2, setSrc2 = signal(0)
|
||||||
|
local order: { string } = {}
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
table.insert(order, "a")
|
||||||
|
local currentSub = setActiveSub(nil)
|
||||||
|
local isOne = src2() == 1
|
||||||
|
setActiveSub(currentSub)
|
||||||
|
if isOne then
|
||||||
|
src1()
|
||||||
|
end
|
||||||
|
src2()
|
||||||
|
src1()
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
table.insert(order, "b")
|
||||||
|
src1()
|
||||||
|
end)
|
||||||
|
setSrc2(1) -- src1.subs: a -> b -> a
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
setSrc1(src1() + 1)
|
||||||
|
|
||||||
|
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should handle side effect with inner effects", function()
|
||||||
|
local a, setA = signal(0)
|
||||||
|
local b, setB = signal(0)
|
||||||
|
local order: { string } = {}
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
effect(function()
|
||||||
|
a()
|
||||||
|
table.insert(order, "a")
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
b()
|
||||||
|
table.insert(order, "b")
|
||||||
|
end)
|
||||||
|
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
setB(1)
|
||||||
|
setA(1)
|
||||||
|
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should handle flags are indirectly updated during checkDirty", function()
|
||||||
|
local a, setA = signal(false)
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
b()
|
||||||
|
return 0
|
||||||
|
end)
|
||||||
|
local d = computed(function()
|
||||||
|
c()
|
||||||
|
return b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local triggers = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
d()
|
||||||
|
triggers += 1
|
||||||
|
end)
|
||||||
|
assert(triggers == 1, "did not run initially")
|
||||||
|
setA(true)
|
||||||
|
assert(triggers == 2, "did not update indirectly")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should handle effect recursion for the first execution", function()
|
||||||
|
local src1, setSrc1 = signal(0)
|
||||||
|
local src2, setSrc2 = signal(0)
|
||||||
|
|
||||||
|
local triggers1 = 0
|
||||||
|
local triggers2 = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
triggers1 += 1
|
||||||
|
setSrc1(math.min(src1() + 1, 5))
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
triggers2 += 1
|
||||||
|
setSrc2(math.min(src2() + 1, 5))
|
||||||
|
src2()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(triggers1 == 1, `bad triggers1: {triggers1}`)
|
||||||
|
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should opt in to recursion", function()
|
||||||
|
local src, setSrc = signal(0)
|
||||||
|
local order = {}
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
recursive()
|
||||||
|
table.insert(order, src())
|
||||||
|
setSrc(math.min(src() + 1, 5))
|
||||||
|
end)
|
||||||
|
assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run cleanups in sequence", function()
|
||||||
|
local order = {}
|
||||||
|
|
||||||
|
local a, setA = signal(false)
|
||||||
|
local stopEffect = effect(function()
|
||||||
|
a()
|
||||||
|
onCleanup(function()
|
||||||
|
table.insert(order, "a")
|
||||||
|
end)
|
||||||
|
onCleanup(function()
|
||||||
|
table.insert(order, "b")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(order) == "", "should not cleanup immediately")
|
||||||
|
|
||||||
|
setA(true)
|
||||||
|
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
stopEffect()
|
||||||
|
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
|
||||||
|
|
||||||
|
table.clear(order)
|
||||||
|
setA(false)
|
||||||
|
assert(table.concat(order) == "", "should not cleanup after update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run inner effect cleanup", function()
|
||||||
|
local cleanups = 0
|
||||||
|
|
||||||
|
local src, setSrc = signal(false)
|
||||||
|
local stopEffect = effect(function()
|
||||||
|
src()
|
||||||
|
effect(function()
|
||||||
|
onCleanup(function()
|
||||||
|
cleanups += 1
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
effect(function()
|
||||||
|
onCleanup(function()
|
||||||
|
cleanups += 1
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(cleanups == 0, "should not cleanup immediately")
|
||||||
|
setSrc(true)
|
||||||
|
assert(cleanups == 2, "should cleanup after outer update")
|
||||||
|
stopEffect()
|
||||||
|
assert(cleanups == 4, "should cleanup after outer dispose")
|
||||||
|
setSrc(false)
|
||||||
|
assert(cleanups == 4, "should not cleanup after update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run cleanups outside any evaluation context", function()
|
||||||
|
local calls = 0
|
||||||
|
local a, setA = signal(0)
|
||||||
|
local b, setB = signal(0)
|
||||||
|
local c = computed(function()
|
||||||
|
if a() == 0 then
|
||||||
|
effect(function()
|
||||||
|
onCleanup(function()
|
||||||
|
b()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
calls += 1
|
||||||
|
c()
|
||||||
|
end)
|
||||||
|
assert(calls == 1, "should run once")
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
setA(1)
|
||||||
|
assert(calls == 1, "should run once after a update")
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
setB(1)
|
||||||
|
assert(calls == 0, "should not run after b update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
83
packages/charm/src/__tests__/effectScope.spec.luau
Normal file
83
packages/charm/src/__tests__/effectScope.spec.luau
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local effect = Charm.effect
|
||||||
|
local effectScope = Charm.effectScope
|
||||||
|
local signal = Charm.signal
|
||||||
|
local onCleanup = Charm.onCleanup
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should not trigger after stop", function()
|
||||||
|
local count, setCount = signal(1)
|
||||||
|
|
||||||
|
local triggers = 0
|
||||||
|
|
||||||
|
local stopScope = effectScope(function()
|
||||||
|
effect(function()
|
||||||
|
triggers += 1
|
||||||
|
count()
|
||||||
|
end)
|
||||||
|
assert(triggers == 1, "did not run initially")
|
||||||
|
|
||||||
|
setCount(2)
|
||||||
|
assert(triggers == 2, "did not run after update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
setCount(3)
|
||||||
|
assert(triggers == 3, "did not run after update outside scope")
|
||||||
|
stopScope()
|
||||||
|
setCount(4)
|
||||||
|
assert(triggers == 3, "ran after stop")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run indirect scope cleanup", function()
|
||||||
|
local cleanups = 0
|
||||||
|
|
||||||
|
local cleanup
|
||||||
|
local stopScope = effectScope(function()
|
||||||
|
cleanup = effectScope(function()
|
||||||
|
onCleanup(function()
|
||||||
|
cleanups += 1
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(cleanups == 0, "should not cleanup immediately")
|
||||||
|
stopScope()
|
||||||
|
assert(cleanups == 1, "should cleanup from parent dispose")
|
||||||
|
cleanup()
|
||||||
|
assert(cleanups == 1, "should skip redundant dispose")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run indirect effect cleanup", function()
|
||||||
|
local cleanups = 0
|
||||||
|
|
||||||
|
local a, setA = signal(false)
|
||||||
|
local stopScope = effectScope(function()
|
||||||
|
effect(function()
|
||||||
|
a()
|
||||||
|
onCleanup(function()
|
||||||
|
cleanups += 1
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(cleanups == 0, "should not cleanup immediately")
|
||||||
|
setA(true)
|
||||||
|
assert(cleanups == 1, "should cleanup once")
|
||||||
|
stopScope()
|
||||||
|
assert(cleanups == 2, "should cleanup after outer dispose")
|
||||||
|
setA(false)
|
||||||
|
assert(cleanups == 2, "should not cleanup after update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should disallow yielding", function()
|
||||||
|
local success = pcall(function()
|
||||||
|
effectScope(function()
|
||||||
|
(if task then task.wait else coroutine.yield :: never)()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(not success, "should error on yield")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
72
packages/charm/src/__tests__/gc.spec.luau
Normal file
72
packages/charm/src/__tests__/gc.spec.luau
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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 {}
|
||||||
118
packages/charm/src/__tests__/listen.spec.luau
Normal file
118
packages/charm/src/__tests__/listen.spec.luau
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local listen = Charm.listen
|
||||||
|
local effect = Charm.effect
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should listen to selector", function()
|
||||||
|
local source, setSource = signal(0)
|
||||||
|
local current, previous
|
||||||
|
|
||||||
|
local unsub = listen(function()
|
||||||
|
return source() + 1
|
||||||
|
end, function(a, b)
|
||||||
|
current = a
|
||||||
|
previous = b
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(current == 1 and previous == nil, "should run initially")
|
||||||
|
setSource(0)
|
||||||
|
assert(current == 1 and previous == nil, "should skip redundant update")
|
||||||
|
setSource(1)
|
||||||
|
assert(current == 2 and previous == 1, "should run with new value")
|
||||||
|
unsub()
|
||||||
|
setSource(2)
|
||||||
|
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run in peek", function()
|
||||||
|
local runs = 0
|
||||||
|
local cleanups = 0
|
||||||
|
local a, setA = signal(0)
|
||||||
|
local b, setB = signal(0)
|
||||||
|
|
||||||
|
listen(a, function()
|
||||||
|
runs += 1
|
||||||
|
b()
|
||||||
|
effect(function()
|
||||||
|
return function()
|
||||||
|
cleanups += 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(runs == 1, "should run once on init")
|
||||||
|
assert(cleanups == 0, "should not cleanup yet")
|
||||||
|
|
||||||
|
setA(1)
|
||||||
|
assert(runs == 2, "should run on a change")
|
||||||
|
assert(cleanups == 1, "should cleanup first call")
|
||||||
|
|
||||||
|
setB(1)
|
||||||
|
assert(runs == 2, "should not run on b change")
|
||||||
|
assert(cleanups == 1, "should not cleanup second call")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run if first value is nil", function()
|
||||||
|
local runs = 0
|
||||||
|
local source, setSource = signal(nil :: number?)
|
||||||
|
|
||||||
|
listen(source, function()
|
||||||
|
runs += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(runs == 1, "should run once on init")
|
||||||
|
setSource(1)
|
||||||
|
assert(runs == 2, "should run once on change")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should 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()
|
||||||
|
local order = {}
|
||||||
|
local a, setA = signal(0)
|
||||||
|
|
||||||
|
listen(function(): "even" | "odd"
|
||||||
|
return if a() % 2 == 0 then "even" else "odd"
|
||||||
|
end, function(value)
|
||||||
|
table.insert(order, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(1)
|
||||||
|
setA(3)
|
||||||
|
setA(4)
|
||||||
|
setA(6)
|
||||||
|
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
108
packages/charm/src/__tests__/mapped.spec.luau
Normal file
108
packages/charm/src/__tests__/mapped.spec.luau
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local mapped = Charm.mapped
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should map state", function()
|
||||||
|
local src, setSrc = signal({ 1, 2, 3 })
|
||||||
|
local map = mapped(src, function(value: number)
|
||||||
|
return value + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(map()) == "234", table.concat(map()))
|
||||||
|
setSrc({ 4, 5, 6 })
|
||||||
|
assert(table.concat(map()) == "567", table.concat(map()))
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not map unchanged values", function()
|
||||||
|
local calls = 0
|
||||||
|
|
||||||
|
local src, setSrc = signal({ 1, 2, 3 })
|
||||||
|
local map = mapped(src, function(value: number)
|
||||||
|
calls += 1
|
||||||
|
return value + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(map()) == "234", table.concat(map()))
|
||||||
|
assert(calls == 3, "should map 3 values")
|
||||||
|
|
||||||
|
setSrc({ 1, 3, 3 })
|
||||||
|
assert(table.concat(map()) == "244", table.concat(map()))
|
||||||
|
assert(calls == 4, "should map 1 change")
|
||||||
|
|
||||||
|
setSrc({})
|
||||||
|
assert(#map() == 0, "should be empty")
|
||||||
|
assert(calls == 4, "should not map for removed values")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not update if no values change", function()
|
||||||
|
local calls = 0
|
||||||
|
|
||||||
|
local src, setSrc = signal({ 1, 2, 3 })
|
||||||
|
local map = mapped(src, function(value: number)
|
||||||
|
calls += 1
|
||||||
|
return value + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
local prev = map()
|
||||||
|
assert(calls == 3, "should map 3 values")
|
||||||
|
assert(table.concat(map()) == "234", table.concat(map()))
|
||||||
|
|
||||||
|
setSrc({ 4, 5, 6, 7 })
|
||||||
|
setSrc({ 1, 2, 3 })
|
||||||
|
map()
|
||||||
|
assert(calls == 3, "should not map unchanged values")
|
||||||
|
assert(table.concat(map()) == "234", table.concat(map()))
|
||||||
|
assert(prev == map(), "should not create new table")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should filter values", function()
|
||||||
|
local calls = 0
|
||||||
|
|
||||||
|
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||||
|
local map = mapped(src, function(value: number)
|
||||||
|
calls += 1
|
||||||
|
return if value < 0 then nil else value
|
||||||
|
end)
|
||||||
|
|
||||||
|
local state = map()
|
||||||
|
assert(calls == 3, "should map 3 values")
|
||||||
|
assert(state.a and state.b and state.c, "should map all values")
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||||
|
state = map()
|
||||||
|
assert(calls == 2, "should map 2 values")
|
||||||
|
assert(state.a == nil, "should remove a")
|
||||||
|
assert(state.b and state.c, "should keep b and c")
|
||||||
|
assert(state.d == 4, "should add d")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should map keys", function()
|
||||||
|
local calls = 0
|
||||||
|
|
||||||
|
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||||
|
local map = mapped(src, function(value: number, key: string)
|
||||||
|
calls += 1
|
||||||
|
return if value < 0 then nil else value, string.upper(key)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local state = map()
|
||||||
|
assert(calls == 3, "should map 3 values")
|
||||||
|
assert(not (state.a and state.b and state.c), "should discard old keys")
|
||||||
|
assert(state.A == 1, "should map a")
|
||||||
|
assert(state.B == 2, "should map b")
|
||||||
|
assert(state.C == 3, "should map c")
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||||
|
state = map()
|
||||||
|
assert(calls == 2, "should map 2 values")
|
||||||
|
assert(state.A == nil and state.a == nil, "should remove a")
|
||||||
|
assert(state.B and state.C, "should keep b and c")
|
||||||
|
assert(state.D == 4, "should add d")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
171
packages/charm/src/__tests__/observe.spec.luau
Normal file
171
packages/charm/src/__tests__/observe.spec.luau
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local effectScope = Charm.effectScope
|
||||||
|
local observe = Charm.observe
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should observe items", function()
|
||||||
|
local items, setItems = signal({ 0, 0, 0 })
|
||||||
|
local updates = {}
|
||||||
|
local cleanups = {}
|
||||||
|
|
||||||
|
observe(items, function(_, key)
|
||||||
|
local get = computed(function()
|
||||||
|
return items()[key]
|
||||||
|
end)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
table.insert(updates, get())
|
||||||
|
end)
|
||||||
|
|
||||||
|
return function()
|
||||||
|
table.insert(cleanups, key)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
|
||||||
|
|
||||||
|
table.clear(updates)
|
||||||
|
setItems({ 1, 2, 3 })
|
||||||
|
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
|
||||||
|
|
||||||
|
table.clear(updates)
|
||||||
|
setItems({ 1, 2, nil :: any, 4 })
|
||||||
|
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
|
||||||
|
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should dispose scopes", function()
|
||||||
|
local items, setItems = signal({ 1, 2, 3 })
|
||||||
|
local a, setA = signal(0)
|
||||||
|
local runs = {}
|
||||||
|
local disposes = {}
|
||||||
|
|
||||||
|
local dispose = observe(items, function(item)
|
||||||
|
effect(function()
|
||||||
|
table.insert(runs, item)
|
||||||
|
a()
|
||||||
|
end)
|
||||||
|
effectScope(function()
|
||||||
|
return function()
|
||||||
|
table.insert(disposes, item)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
table.clear(runs)
|
||||||
|
setItems({ 1, 2 })
|
||||||
|
setA(1)
|
||||||
|
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
|
||||||
|
assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
|
||||||
|
|
||||||
|
table.clear(runs)
|
||||||
|
table.clear(disposes)
|
||||||
|
dispose()
|
||||||
|
setA(2)
|
||||||
|
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
|
||||||
|
assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should queue addition and removal", function()
|
||||||
|
local items, setItems = signal({ 1 })
|
||||||
|
local edits = {}
|
||||||
|
|
||||||
|
observe(items, function(item)
|
||||||
|
if item == 1 then
|
||||||
|
setItems({ 1, 2, 3, 4 })
|
||||||
|
elseif item == 4 then
|
||||||
|
setItems({ 1 })
|
||||||
|
end
|
||||||
|
table.insert(edits, `+{item}`)
|
||||||
|
return function()
|
||||||
|
table.insert(edits, `-{item}`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should handle recursive additions and removals", function()
|
||||||
|
local items, setItems = signal({ 1 })
|
||||||
|
local edits = {}
|
||||||
|
|
||||||
|
observe(items, function(item)
|
||||||
|
if item < 5 then
|
||||||
|
setItems(function(state)
|
||||||
|
state = table.clone(state)
|
||||||
|
table.insert(state, item + 1)
|
||||||
|
return state
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
setItems({})
|
||||||
|
end
|
||||||
|
table.insert(edits, `+{item}`)
|
||||||
|
return function()
|
||||||
|
table.insert(edits, `-{item}`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should handle self removal", function()
|
||||||
|
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||||
|
local edits = {}
|
||||||
|
|
||||||
|
observe(items, function(item, index)
|
||||||
|
setItems(function(state)
|
||||||
|
state = table.clone(state)
|
||||||
|
state[index] = nil
|
||||||
|
return state
|
||||||
|
end)
|
||||||
|
table.insert(edits, `+{item}`)
|
||||||
|
return function()
|
||||||
|
table.insert(edits, `-{item}`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should allow disposal during addition", function()
|
||||||
|
local items, setItems = signal({})
|
||||||
|
local edits = {}
|
||||||
|
local dispose
|
||||||
|
|
||||||
|
dispose = observe(items, function(item)
|
||||||
|
if item == 1 then
|
||||||
|
dispose()
|
||||||
|
end
|
||||||
|
table.insert(edits, `+{item}`)
|
||||||
|
return function()
|
||||||
|
table.insert(edits, `-{item}`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
setItems({ 1, 2, 3, 4 })
|
||||||
|
assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should allow disposal during removal", function()
|
||||||
|
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||||
|
local edits = {}
|
||||||
|
local dispose
|
||||||
|
|
||||||
|
dispose = observe(items, function(item)
|
||||||
|
table.insert(edits, `+{item}`)
|
||||||
|
return function()
|
||||||
|
dispose()
|
||||||
|
table.insert(edits, `-{item}`)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
setItems({ 1, 2, 3, nil :: any, 5 })
|
||||||
|
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
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 {}
|
||||||
124
packages/charm/src/__tests__/reactive.spec.luau
Normal file
124
packages/charm/src/__tests__/reactive.spec.luau
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local effect = Charm.effect
|
||||||
|
local reactive = Charm.reactive
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
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, runs)
|
||||||
|
|
||||||
|
src.a, src.b = src.b, src.a
|
||||||
|
assert(runs == 3, 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 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, calls)
|
||||||
|
|
||||||
|
src.value.b = 3
|
||||||
|
assert(calls == 1, 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({ a = 2, c = 4 })
|
||||||
|
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
50
packages/charm/src/__tests__/signal.spec.luau
Normal file
50
packages/charm/src/__tests__/signal.spec.luau
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local effect = Charm.effect
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should accept equality function", function()
|
||||||
|
local calls = {}
|
||||||
|
local src, setSrc = signal(0, function(current, incoming)
|
||||||
|
return current == incoming or incoming % 2 == 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
table.insert(calls, src())
|
||||||
|
end)
|
||||||
|
|
||||||
|
setSrc(2)
|
||||||
|
setSrc(3)
|
||||||
|
assert(src() == 2, "should skip odd value")
|
||||||
|
|
||||||
|
setSrc(4)
|
||||||
|
setSrc(5)
|
||||||
|
assert(src() == 4, "should skip odd value")
|
||||||
|
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should enforce immutability", function()
|
||||||
|
local src, setSrc = signal({ nested = {} })
|
||||||
|
assert(not pcall(function()
|
||||||
|
src().nested.a = 1
|
||||||
|
end), "should not allow mutation of initial value")
|
||||||
|
|
||||||
|
setSrc({ nested = { a = 1 } })
|
||||||
|
assert(not pcall(function()
|
||||||
|
src().nested.a = 2
|
||||||
|
end), "should not allow mutation of new value")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not freeze values with metatables", function()
|
||||||
|
local src = signal({ object = setmetatable({}, {}) })
|
||||||
|
assert(
|
||||||
|
pcall(function()
|
||||||
|
src().object.a = 2
|
||||||
|
end),
|
||||||
|
"should allow mutation of values with metatables"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
46
packages/charm/src/__tests__/spec.luau
Normal file
46
packages/charm/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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("reactive", function()
|
||||||
|
require("./reactive.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 {}
|
||||||
134
packages/charm/src/__tests__/subscribe.spec.luau
Normal file
134
packages/charm/src/__tests__/subscribe.spec.luau
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local computed = Charm.computed
|
||||||
|
local subscribe = Charm.subscribe
|
||||||
|
local listen = Charm.listen
|
||||||
|
local effect = Charm.effect
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should subscribe to selector", function()
|
||||||
|
local source, setSource = signal(0)
|
||||||
|
local current, previous
|
||||||
|
|
||||||
|
local unsub = subscribe(function()
|
||||||
|
return source() + 1
|
||||||
|
end, function(a, b)
|
||||||
|
current = a
|
||||||
|
previous = b
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(not current and not previous, "should not run initially")
|
||||||
|
setSource(0)
|
||||||
|
assert(not current and not previous, "should skip redundant update")
|
||||||
|
setSource(1)
|
||||||
|
assert(current == 2 and previous == 1, "should run with new value")
|
||||||
|
unsub()
|
||||||
|
setSource(2)
|
||||||
|
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run in peek", function()
|
||||||
|
local runs = 0
|
||||||
|
local cleanups = 0
|
||||||
|
local a, setA = signal(0)
|
||||||
|
local b, setB = signal(0)
|
||||||
|
|
||||||
|
subscribe(a, function()
|
||||||
|
runs += 1
|
||||||
|
b()
|
||||||
|
effect(function()
|
||||||
|
return function()
|
||||||
|
cleanups += 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(runs == 0, "should not run once on init")
|
||||||
|
assert(cleanups == 0, "should not cleanup yet")
|
||||||
|
|
||||||
|
setA(1)
|
||||||
|
assert(runs == 1, "should run once on change")
|
||||||
|
assert(cleanups == 0, "should not cleanup yet")
|
||||||
|
|
||||||
|
setB(1)
|
||||||
|
assert(runs == 1, "should not run on b change")
|
||||||
|
assert(cleanups == 0, "should not cleanup first call")
|
||||||
|
|
||||||
|
setA(2)
|
||||||
|
assert(runs == 2, "should run on a change")
|
||||||
|
assert(cleanups == 1, "should cleanup first call")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should ensure correct execution order", function()
|
||||||
|
local order = {}
|
||||||
|
local n1, setN1 = signal(0)
|
||||||
|
local n2, setN2 = signal(0)
|
||||||
|
local sum = computed(function()
|
||||||
|
return n1() + n2()
|
||||||
|
end)
|
||||||
|
|
||||||
|
subscribe(n1, function()
|
||||||
|
table.insert(order, 1)
|
||||||
|
setN2(n2() + 1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
subscribe(sum, function()
|
||||||
|
table.insert(order, 2)
|
||||||
|
end)
|
||||||
|
|
||||||
|
subscribe(n1, function()
|
||||||
|
table.insert(order, 3)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setN1(n1() + 1)
|
||||||
|
|
||||||
|
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should 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()
|
||||||
|
local order = {}
|
||||||
|
local a, setA = signal(0)
|
||||||
|
|
||||||
|
subscribe(function(): "even" | "odd"
|
||||||
|
return if a() % 2 == 0 then "even" else "odd"
|
||||||
|
end, function(value)
|
||||||
|
table.insert(order, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(1)
|
||||||
|
setA(3)
|
||||||
|
setA(4)
|
||||||
|
setA(6)
|
||||||
|
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
490
packages/charm/src/__tests__/topology.spec.luau
Normal file
490
packages/charm/src/__tests__/topology.spec.luau
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local atom = Charm.atom
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local describe = suite.describe
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Tests adopted with thanks from preact-core implementation at
|
||||||
|
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
|
||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2022-present Preact Team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE
|
||||||
|
]]
|
||||||
|
|
||||||
|
describe("graph updates", function()
|
||||||
|
test("should drop A->B->A updates", function()
|
||||||
|
-- A
|
||||||
|
-- / |
|
||||||
|
-- B | <- Looks like a flag doesn't it? :D
|
||||||
|
-- \ |
|
||||||
|
-- C
|
||||||
|
-- |
|
||||||
|
-- D
|
||||||
|
local a = atom(2)
|
||||||
|
|
||||||
|
local b = computed(function()
|
||||||
|
return a() - 1
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return a() + b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local computeCalls = 0
|
||||||
|
local compute = function()
|
||||||
|
computeCalls += 1
|
||||||
|
return "d: " .. c()
|
||||||
|
end
|
||||||
|
local d = computed(compute)
|
||||||
|
|
||||||
|
-- Trigger read
|
||||||
|
assert(d() == "d: 3", "d should be 3")
|
||||||
|
assert(computeCalls == 1, "compute should be called once")
|
||||||
|
computeCalls = 0
|
||||||
|
|
||||||
|
a(4)
|
||||||
|
d()
|
||||||
|
assert(computeCalls == 1, "compute should drop update")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should only update every signal once (diamond graph)", function()
|
||||||
|
-- In this scenario "D" should only update once when "A" receives
|
||||||
|
-- an update. This is sometimes referred to as the "diamond" scenario.
|
||||||
|
-- A
|
||||||
|
-- / \
|
||||||
|
-- B C
|
||||||
|
-- \ /
|
||||||
|
-- D
|
||||||
|
local a = atom("a")
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local spyCalls = 0
|
||||||
|
local spy = function()
|
||||||
|
spyCalls += 1
|
||||||
|
return b() .. " " .. c()
|
||||||
|
end
|
||||||
|
local d = computed(spy)
|
||||||
|
|
||||||
|
assert(d() == "a a", "d should be 'a a'")
|
||||||
|
assert(spyCalls == 1, "compute should be called once")
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
assert(d() == "aa aa", "d should be 'aa aa'")
|
||||||
|
assert(spyCalls == 2, "compute should be called only twice")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should only update every signal once (diamond graph + tail)", function()
|
||||||
|
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
|
||||||
|
-- A
|
||||||
|
-- / \
|
||||||
|
-- B C
|
||||||
|
-- \ /
|
||||||
|
-- D
|
||||||
|
-- |
|
||||||
|
-- E
|
||||||
|
local a = atom("a")
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local d = computed(function()
|
||||||
|
return b() .. " " .. c()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local spyCalls = 0
|
||||||
|
local spy = function()
|
||||||
|
spyCalls += 1
|
||||||
|
return d()
|
||||||
|
end
|
||||||
|
local e = computed(spy)
|
||||||
|
|
||||||
|
assert(e() == "a a", "e should be 'a a'")
|
||||||
|
assert(spyCalls == 1, "compute should be called once")
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
assert(e() == "aa aa", "e should be 'aa aa'")
|
||||||
|
assert(spyCalls == 2, "compute should be called only twice")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should bail out if result is the same", function()
|
||||||
|
-- Bail out if value of "B" never changes
|
||||||
|
-- A->B->C
|
||||||
|
local a = atom("a")
|
||||||
|
local b = computed(function()
|
||||||
|
a()
|
||||||
|
return "foo"
|
||||||
|
end)
|
||||||
|
|
||||||
|
local spyCalls = 0
|
||||||
|
local spy = function()
|
||||||
|
spyCalls += 1
|
||||||
|
return b()
|
||||||
|
end
|
||||||
|
local c = computed(spy)
|
||||||
|
|
||||||
|
assert(c() == "foo", "c should be 'foo'")
|
||||||
|
assert(spyCalls == 1, "compute should be called once")
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
assert(c() == "foo", "c should be 'foo'")
|
||||||
|
assert(spyCalls == 1, "compute should bail out")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should only update every signal once (jagged diamond graph + tails)", function()
|
||||||
|
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
|
||||||
|
-- A
|
||||||
|
-- / \
|
||||||
|
-- B C
|
||||||
|
-- | |
|
||||||
|
-- | D
|
||||||
|
-- \ /
|
||||||
|
-- E
|
||||||
|
-- / \
|
||||||
|
-- F G
|
||||||
|
local a = atom("a")
|
||||||
|
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local d = computed(function()
|
||||||
|
return c()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local eSpyCalls = 0
|
||||||
|
local eSpyTime = 0
|
||||||
|
local eSpy = function()
|
||||||
|
eSpyCalls += 1
|
||||||
|
eSpyTime = os.clock()
|
||||||
|
return b() .. " " .. d()
|
||||||
|
end
|
||||||
|
local e = computed(eSpy)
|
||||||
|
|
||||||
|
local fSpyCalls = 0
|
||||||
|
local fSpyTime = 0
|
||||||
|
local fSpy = function()
|
||||||
|
fSpyCalls += 1
|
||||||
|
fSpyTime = os.clock()
|
||||||
|
return e()
|
||||||
|
end
|
||||||
|
local f = computed(fSpy)
|
||||||
|
local gSpyCalls = 0
|
||||||
|
local gSpyTime = 0
|
||||||
|
local gSpy = function()
|
||||||
|
gSpyCalls += 1
|
||||||
|
gSpyTime = os.clock()
|
||||||
|
return e()
|
||||||
|
end
|
||||||
|
local g = computed(gSpy)
|
||||||
|
|
||||||
|
assert(f() == "a a", "f should be 'a a'")
|
||||||
|
assert(fSpyCalls == 1, "f should be called once")
|
||||||
|
|
||||||
|
assert(g() == "a a", "g should be 'a a'")
|
||||||
|
assert(gSpyCalls == 1, "g should be called once")
|
||||||
|
|
||||||
|
eSpyCalls, eSpyTime = 0, 0
|
||||||
|
fSpyCalls, fSpyTime = 0, 0
|
||||||
|
gSpyCalls, gSpyTime = 0, 0
|
||||||
|
|
||||||
|
a("b")
|
||||||
|
|
||||||
|
assert(e() == "b b", "e should be 'b b'")
|
||||||
|
assert(eSpyCalls == 1, "e should run once after update")
|
||||||
|
|
||||||
|
assert(f() == "b b", "f should be 'b b'")
|
||||||
|
assert(fSpyCalls == 1, "f should run once after update")
|
||||||
|
|
||||||
|
assert(g() == "b b", "g should be 'b b'")
|
||||||
|
assert(gSpyCalls == 1, "g should run once after update")
|
||||||
|
|
||||||
|
eSpyCalls, eSpyTime = 0, 0
|
||||||
|
fSpyCalls, fSpyTime = 0, 0
|
||||||
|
gSpyCalls, gSpyTime = 0, 0
|
||||||
|
|
||||||
|
a("c")
|
||||||
|
|
||||||
|
assert(e() == "c c", "e should be 'c c'")
|
||||||
|
assert(eSpyCalls == 1, "e should run once after update")
|
||||||
|
|
||||||
|
assert(f() == "c c", "f should be 'c c'")
|
||||||
|
assert(fSpyCalls == 1, "f should run once after update")
|
||||||
|
|
||||||
|
assert(g() == "c c", "g should be 'c c'")
|
||||||
|
assert(gSpyCalls == 1, "g should run once after update")
|
||||||
|
|
||||||
|
-- top to bottom
|
||||||
|
assert(eSpyTime < fSpyTime, "e should be called before f")
|
||||||
|
-- left to right
|
||||||
|
assert(eSpyTime < gSpyTime, "e should be called before g")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should only subscribe to core listened to", function()
|
||||||
|
-- *A
|
||||||
|
-- / \
|
||||||
|
-- *B C <- we don't listen to C
|
||||||
|
local a = atom("a")
|
||||||
|
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local spyCalls = 0
|
||||||
|
local spy = function()
|
||||||
|
spyCalls += 1
|
||||||
|
return a()
|
||||||
|
end
|
||||||
|
computed(spy)
|
||||||
|
|
||||||
|
assert(b() == "a", "b should be 'a'")
|
||||||
|
assert(spyCalls == 0, "compute should not run")
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
assert(b() == "aa", "b should be 'aa'")
|
||||||
|
assert(spyCalls == 0, "compute should not run")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should only subscribe to core listened to II", function()
|
||||||
|
-- Here both "B" and "C" are active in the beginning, but
|
||||||
|
-- "B" becomes inactive later. At that point it should
|
||||||
|
-- not receive any updates anymore.
|
||||||
|
-- *A
|
||||||
|
-- / \
|
||||||
|
-- *B D <- we don't listen to C
|
||||||
|
-- |
|
||||||
|
-- *C
|
||||||
|
local a = atom("a")
|
||||||
|
local spyBCalls = 0
|
||||||
|
local spyB = function()
|
||||||
|
spyBCalls += 1
|
||||||
|
return a()
|
||||||
|
end
|
||||||
|
local b = computed(spyB)
|
||||||
|
|
||||||
|
local spyCCalls = 0
|
||||||
|
local spyC = function()
|
||||||
|
spyCCalls += 1
|
||||||
|
return b()
|
||||||
|
end
|
||||||
|
local c = computed(spyC)
|
||||||
|
|
||||||
|
local d = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local result = ""
|
||||||
|
local unsub = effect(function()
|
||||||
|
result = c()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(result == "a", "result should be 'a'")
|
||||||
|
assert(d() == "a", "d should be 'a'")
|
||||||
|
|
||||||
|
spyBCalls = 0
|
||||||
|
spyCCalls = 0
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
|
||||||
|
assert(spyBCalls == 0, "b should not run")
|
||||||
|
assert(spyCCalls == 0, "c should not run")
|
||||||
|
assert(d() == "aa", "d should be 'aa'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should ensure subs update even if one dep unmarks it", function()
|
||||||
|
-- In this scenario "C" always returns the same value. When "A"
|
||||||
|
-- changes, "B" will update, then "C" at which point its update
|
||||||
|
-- to "D" will be unmarked. But "D" must still update because
|
||||||
|
-- "B" marked it. If "D" isn't updated, then we have a bug.
|
||||||
|
-- A
|
||||||
|
-- / \
|
||||||
|
-- B *C <- returns same value every time
|
||||||
|
-- \ /
|
||||||
|
-- D
|
||||||
|
local a = atom("a")
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
a()
|
||||||
|
return "c"
|
||||||
|
end)
|
||||||
|
local spyResult: string?
|
||||||
|
local spy = function()
|
||||||
|
spyResult = b() .. " " .. c()
|
||||||
|
return spyResult
|
||||||
|
end
|
||||||
|
local d = computed(spy)
|
||||||
|
|
||||||
|
assert(d() == "a c", "d should be 'a c'")
|
||||||
|
spyResult = nil
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
d()
|
||||||
|
assert(spyResult == "aa c", "d should have returned 'aa c'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should ensure subs update even if two deps unmark it", function()
|
||||||
|
-- In this scenario both "C" and "D" always return the same
|
||||||
|
-- value. But "E" must still update because "A" marked it.
|
||||||
|
-- If "E" isn't updated, then we have a bug.
|
||||||
|
-- A
|
||||||
|
-- / | \
|
||||||
|
-- B *C *D
|
||||||
|
-- \ | /
|
||||||
|
-- E
|
||||||
|
local a = atom("a")
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
a()
|
||||||
|
return "c"
|
||||||
|
end)
|
||||||
|
local d = computed(function()
|
||||||
|
a()
|
||||||
|
return "d"
|
||||||
|
end)
|
||||||
|
local spyResult: string?
|
||||||
|
local spy = function()
|
||||||
|
spyResult = b() .. " " .. c() .. " " .. d()
|
||||||
|
return spyResult
|
||||||
|
end
|
||||||
|
local e = computed(spy)
|
||||||
|
|
||||||
|
assert(e() == "a c d", "e should be 'a c d'")
|
||||||
|
spyResult = nil
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
e()
|
||||||
|
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should support lazy branches", function()
|
||||||
|
local a = atom(0)
|
||||||
|
local b = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return if a() > 0 then a() else b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(c() == 0, "c should be 0")
|
||||||
|
a(1)
|
||||||
|
assert(c() == 1, "c should be 1")
|
||||||
|
|
||||||
|
a(0)
|
||||||
|
assert(c() == 0, "c should be 0 again")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not update a sub if all deps unmark it", function()
|
||||||
|
-- In this scenario "B" and "C" always return the same value. When "A"
|
||||||
|
-- changes, "D" should not update.
|
||||||
|
-- A
|
||||||
|
-- / \
|
||||||
|
-- *B *C
|
||||||
|
-- \ /
|
||||||
|
-- D
|
||||||
|
local a = atom("a")
|
||||||
|
local b = computed(function()
|
||||||
|
a()
|
||||||
|
return "b"
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
a()
|
||||||
|
return "c"
|
||||||
|
end)
|
||||||
|
local spyCalls = 0
|
||||||
|
local spy = function()
|
||||||
|
spyCalls += 1
|
||||||
|
return b() .. " " .. c()
|
||||||
|
end
|
||||||
|
local d = computed(spy)
|
||||||
|
|
||||||
|
assert(d() == "b c", "d should be 'b c'")
|
||||||
|
spyCalls = 0
|
||||||
|
|
||||||
|
a("aa")
|
||||||
|
assert(spyCalls == 0, "d should not run")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe("error handling", function()
|
||||||
|
test("should keep graph consistent on errors during activation", function()
|
||||||
|
local a = atom(0)
|
||||||
|
local b = computed(function()
|
||||||
|
error("fail")
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local success, result = pcall(function()
|
||||||
|
b()
|
||||||
|
end)
|
||||||
|
assert(not success, "should throw")
|
||||||
|
assert(string.match(result, "fail"), "should throw fail")
|
||||||
|
|
||||||
|
a(1)
|
||||||
|
assert(c() == 1, "c should recover and return 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should keep graph consistent on errors in computeds", function()
|
||||||
|
local a = atom(0)
|
||||||
|
local b = computed(function()
|
||||||
|
if a() == 1 then
|
||||||
|
error("fail")
|
||||||
|
end
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
local c = computed(function()
|
||||||
|
return b()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(c() == 0, "c should be 0")
|
||||||
|
|
||||||
|
a(1)
|
||||||
|
local success, result = pcall(function()
|
||||||
|
b()
|
||||||
|
end)
|
||||||
|
assert(not success, "should throw")
|
||||||
|
assert(string.match(result, "fail"), "should throw fail")
|
||||||
|
|
||||||
|
a(2)
|
||||||
|
assert(c() == 2, "c should recover and return 2")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
85
packages/charm/src/__tests__/trigger.spec.luau
Normal file
85
packages/charm/src/__tests__/trigger.spec.luau
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local signal = Charm.signal
|
||||||
|
local subscribe = Charm.subscribe
|
||||||
|
local trigger = Charm.trigger
|
||||||
|
local test = suite.test
|
||||||
|
local beforeEach = suite.beforeEach
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
|
beforeEach(function()
|
||||||
|
Charm.flags.frozen = false
|
||||||
|
end)
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
Charm.flags.frozen = true
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not throw when triggering with no dependencies", function()
|
||||||
|
trigger(function() end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should trigger updates for dependent computed signals", function()
|
||||||
|
local arr = signal({} :: { number })
|
||||||
|
local length = computed(function()
|
||||||
|
return #arr()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(length() == 0, "initial length should be 0")
|
||||||
|
table.insert(arr(), 1)
|
||||||
|
trigger(arr)
|
||||||
|
assert(length() == 1, "length should be 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should trigger updates for the second source signal", function()
|
||||||
|
local src1 = signal({} :: { number })
|
||||||
|
local src2 = signal({} :: { number })
|
||||||
|
local length = computed(function()
|
||||||
|
return #src2()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(length() == 0, "initial length should be 0")
|
||||||
|
table.insert(src2(), 1)
|
||||||
|
trigger(function()
|
||||||
|
src1()
|
||||||
|
src2()
|
||||||
|
end)
|
||||||
|
assert(length() == 1, "length should be 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should trigger effect once", function()
|
||||||
|
local src1 = signal({} :: { number })
|
||||||
|
local src2 = signal({} :: { number })
|
||||||
|
local triggers = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
triggers += 1
|
||||||
|
src1()
|
||||||
|
src2()
|
||||||
|
end)
|
||||||
|
assert(triggers == 1, "effect should run once initially")
|
||||||
|
|
||||||
|
trigger(function()
|
||||||
|
src1()
|
||||||
|
src2()
|
||||||
|
end)
|
||||||
|
assert(triggers == 2, "effect should run twice")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should trigger updates for subscribe", function()
|
||||||
|
local src = signal({} :: { number })
|
||||||
|
local triggers = 0
|
||||||
|
|
||||||
|
subscribe(src, function()
|
||||||
|
triggers += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
table.insert(src(), 1)
|
||||||
|
trigger(src)
|
||||||
|
assert(triggers == 1, "subscribe callback should run after trigger")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
177
packages/charm/src/__tests__/untracked.spec.luau
Normal file
177
packages/charm/src/__tests__/untracked.spec.luau
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
local Charm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local signal = Charm.signal
|
||||||
|
local computed = Charm.computed
|
||||||
|
local effect = Charm.effect
|
||||||
|
local effectScope = Charm.effectScope
|
||||||
|
local untracked = Charm.untracked
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("should run detached from effect", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local effects = 0
|
||||||
|
local innerEffects = {}
|
||||||
|
|
||||||
|
local stopEffect = effect(function()
|
||||||
|
a()
|
||||||
|
untracked(function()
|
||||||
|
local stopInnerEffect = effect(function()
|
||||||
|
effects += 1
|
||||||
|
return function()
|
||||||
|
effects -= 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
table.insert(innerEffects, stopInnerEffect)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(effects == 1, "should have one effect")
|
||||||
|
setA(2)
|
||||||
|
assert(effects == 2, "should have two effects")
|
||||||
|
stopEffect()
|
||||||
|
assert(effects == 2, "should still have two effects")
|
||||||
|
for _, stopInnerEffect in innerEffects do
|
||||||
|
stopInnerEffect()
|
||||||
|
end
|
||||||
|
assert(effects == 0, "should have zero effects")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should run detached from effect scope", function()
|
||||||
|
local effects = 0
|
||||||
|
local stopEffect
|
||||||
|
|
||||||
|
local stopScope = effectScope(function()
|
||||||
|
untracked(function()
|
||||||
|
stopEffect = effect(function()
|
||||||
|
effects += 1
|
||||||
|
return function()
|
||||||
|
effects -= 1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(effects == 1, "should have one effect")
|
||||||
|
stopScope()
|
||||||
|
assert(effects == 1, "should still have one effect")
|
||||||
|
stopEffect()
|
||||||
|
assert(effects == 0, "should have zero effects")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should block tracking inside effects", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(2)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
untracked(function()
|
||||||
|
spyCalls += 1
|
||||||
|
return a() + b()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
|
||||||
|
setA(10)
|
||||||
|
setB(20)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should block tracking even when run inside effect run inside untracked", function()
|
||||||
|
local s, setS = signal(1)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
untracked(function()
|
||||||
|
effect(function()
|
||||||
|
untracked(function()
|
||||||
|
spyCalls += 1
|
||||||
|
return s()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
|
||||||
|
setS(2)
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should not cause signal assignments to throw", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local aChangedTime, setAChangedTime = signal(0)
|
||||||
|
|
||||||
|
local dispose = effect(function()
|
||||||
|
a()
|
||||||
|
untracked(function()
|
||||||
|
setAChangedTime(aChangedTime() + 1)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setA(2)
|
||||||
|
assert(aChangedTime() == 2, "aChangedTime should be 2")
|
||||||
|
setA(3)
|
||||||
|
assert(aChangedTime() == 3, "aChangedTime should be 3")
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should block tracking inside computed signals", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(2)
|
||||||
|
local spyCalls = 0
|
||||||
|
local c = computed(function()
|
||||||
|
return untracked(function()
|
||||||
|
spyCalls += 1
|
||||||
|
return a() + b()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 0, "should not be called yet")
|
||||||
|
assert(c() == 3, "c should be 3")
|
||||||
|
setA(10)
|
||||||
|
c()
|
||||||
|
setB(20)
|
||||||
|
c()
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
assert(c() == 3, "c should be 3")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should track outside untracked", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local b, setB = signal(2)
|
||||||
|
local c, setC = signal(3)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
a()
|
||||||
|
untracked(c)
|
||||||
|
b()
|
||||||
|
spyCalls += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
setA(10)
|
||||||
|
assert(spyCalls == 2, "should be called twice")
|
||||||
|
setB(20)
|
||||||
|
assert(spyCalls == 3, "should be called three times")
|
||||||
|
setC(30)
|
||||||
|
assert(spyCalls == 3, "should not run on c change")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("should stack untracked", function()
|
||||||
|
local a, setA = signal(1)
|
||||||
|
local spyCalls = 0
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
untracked(function()
|
||||||
|
untracked(a)
|
||||||
|
return a()
|
||||||
|
end)
|
||||||
|
spyCalls += 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert(spyCalls == 1, "should be called once")
|
||||||
|
setA(2)
|
||||||
|
assert(spyCalls == 1, "should not be called twice")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
local store = require(script.Parent.store)
|
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type Atom<T> = types.Atom<T>
|
|
||||||
type AtomOptions<T> = types.AtomOptions<T>
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Creates a new atom with the given state.
|
|
||||||
|
|
||||||
@param state The initial state.
|
|
||||||
@param options Optional configuration.
|
|
||||||
@return A new atom.
|
|
||||||
]=]
|
|
||||||
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
|
|
||||||
local equals = options and options.equals
|
|
||||||
|
|
||||||
local function atom(...)
|
|
||||||
if select("#", ...) == 0 then
|
|
||||||
local index = store.capturing.index
|
|
||||||
|
|
||||||
if index > 0 then
|
|
||||||
store.capturing.stack[index][atom] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
return state
|
|
||||||
end
|
|
||||||
|
|
||||||
local nextState = store.peek(..., state)
|
|
||||||
|
|
||||||
if state ~= nextState and not (equals and equals(state, nextState)) then
|
|
||||||
state = nextState
|
|
||||||
store.notify(atom)
|
|
||||||
end
|
|
||||||
|
|
||||||
return state
|
|
||||||
end
|
|
||||||
|
|
||||||
store.listeners[atom] = setmetatable({}, { __mode = "v" })
|
|
||||||
|
|
||||||
return atom
|
|
||||||
end
|
|
||||||
|
|
||||||
return atom
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
local atom = require(script.Parent.atom)
|
|
||||||
local store = require(script.Parent.store)
|
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type AtomOptions<T> = types.AtomOptions<T>
|
|
||||||
type Selector<T> = types.Selector<T>
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Creates a read-only atom that derives its state from one or more atoms.
|
|
||||||
Used to avoid unnecessary recomputations if multiple listeners depend on
|
|
||||||
the same atoms.
|
|
||||||
|
|
||||||
@param callback The function that produces the state.
|
|
||||||
@param options Optional configuration.
|
|
||||||
@return A new read-only atom.
|
|
||||||
]=]
|
|
||||||
local function computed<T>(callback: Selector<T>, options: AtomOptions<T>?): Selector<T>
|
|
||||||
local dependencies, state = store.capture(callback)
|
|
||||||
local computedAtom = atom(state, options)
|
|
||||||
local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
|
|
||||||
|
|
||||||
local function listener()
|
|
||||||
local computedAtom = computedRef.current
|
|
||||||
|
|
||||||
if computedAtom then
|
|
||||||
store.disconnect(dependencies, listener)
|
|
||||||
dependencies, state = store.capture(callback)
|
|
||||||
store.connect(dependencies, listener, computedAtom)
|
|
||||||
computedAtom(state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
store.connect(dependencies, listener, computedAtom)
|
|
||||||
|
|
||||||
return computedAtom
|
|
||||||
end
|
|
||||||
|
|
||||||
return computed
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
local store = require(script.Parent.store)
|
|
||||||
|
|
||||||
type Cleanup = () -> ()
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Runs the given callback immediately and whenever any atom it depends on
|
|
||||||
changes. Returns a cleanup function that unsubscribes the callback.
|
|
||||||
|
|
||||||
@param callback The function to run.
|
|
||||||
@return A function that unsubscribes the callback.
|
|
||||||
]=]
|
|
||||||
local function effect(callback: (cleanup: Cleanup) -> Cleanup?): Cleanup
|
|
||||||
local dependencies = {}
|
|
||||||
local cleanup: Cleanup?
|
|
||||||
local disconnected = false
|
|
||||||
local disconnect
|
|
||||||
|
|
||||||
local function listener()
|
|
||||||
if cleanup then
|
|
||||||
cleanup()
|
|
||||||
end
|
|
||||||
|
|
||||||
store.disconnect(dependencies, listener)
|
|
||||||
dependencies, cleanup = store.capture(callback, disconnect)
|
|
||||||
|
|
||||||
if not disconnected then
|
|
||||||
store.connect(dependencies, listener)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function disconnect()
|
|
||||||
if disconnected then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
disconnected = true
|
|
||||||
store.disconnect(dependencies, listener)
|
|
||||||
|
|
||||||
if cleanup then
|
|
||||||
cleanup()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
dependencies, cleanup = store.capture(callback, disconnect)
|
|
||||||
|
|
||||||
if not disconnected then
|
|
||||||
store.connect(dependencies, listener)
|
|
||||||
end
|
|
||||||
|
|
||||||
return disconnect
|
|
||||||
end
|
|
||||||
|
|
||||||
return effect :: (callback: ((cleanup: Cleanup) -> ()) | ((cleanup: Cleanup) -> Cleanup?)) -> Cleanup
|
|
||||||
500
packages/charm/src/index.d.ts
vendored
500
packages/charm/src/index.d.ts
vendored
|
|
@ -1,187 +1,319 @@
|
||||||
export = Charm;
|
type Key = string | number | symbol;
|
||||||
export as namespace Charm;
|
|
||||||
|
|
||||||
type Cleanup = () => void;
|
type Cleanup = () => void;
|
||||||
|
|
||||||
declare namespace Charm {
|
export enum ReactiveFlags {
|
||||||
/**
|
None = 0b0000000,
|
||||||
* A primitive state container that can be read from or written to. When
|
Mutable = 0b0000001,
|
||||||
* the state changes, all subscribers are notified.
|
Watching = 0b0000010,
|
||||||
*
|
RecursedCheck = 0b0000100,
|
||||||
* @param state The next state or a function that produces the next state.
|
Recursed = 0b0001000,
|
||||||
* @returns The current state.
|
Dirty = 0b0010000,
|
||||||
*/
|
Pending = 0b0100000,
|
||||||
interface Atom<State> extends Selector<State> {
|
Peeking = 0b1000000,
|
||||||
(nextState: State | ((prevState: State) => State)): State;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A function that depends on one or more atoms and produces a value. Can be
|
|
||||||
* used to derive state from atoms.
|
|
||||||
*
|
|
||||||
* @returns The current state.
|
|
||||||
*/
|
|
||||||
type Selector<State> = () => State;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A function that depends on one or more atoms and produces a value. Can be
|
|
||||||
* used to derive state from atoms.
|
|
||||||
*
|
|
||||||
* @deprecated Use `Selector<T>` instead.
|
|
||||||
* @returns The current state.
|
|
||||||
*/
|
|
||||||
type Molecule<State> = Selector<State>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infers the type of the state produced by the given function.
|
|
||||||
*/
|
|
||||||
type StateOf<T> = T extends Selector<infer State> ? State : never;
|
|
||||||
|
|
||||||
interface AtomOptions<State> {
|
|
||||||
/**
|
|
||||||
* A function that determines whether the state has changed. By default,
|
|
||||||
* a strict equality check (`===`) is used.
|
|
||||||
*/
|
|
||||||
equals?: (prev: State, next: State) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new atom with the given state.
|
|
||||||
*
|
|
||||||
* @param state The initial state.
|
|
||||||
* @param options Optional configuration.
|
|
||||||
* @returns A new atom.
|
|
||||||
*/
|
|
||||||
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
|
|
||||||
|
|
||||||
// Overload for no arguments
|
|
||||||
function atom<State>(state?: State, options?: AtomOptions<State>): Atom<State | undefined>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a read-only atom that derives its state from one or more atoms.
|
|
||||||
* Used to avoid unnecessary recomputations if multiple listeners depend on
|
|
||||||
* the same atoms.
|
|
||||||
*
|
|
||||||
* @param callback The function that produces the state.
|
|
||||||
* @param options Optional configuration.
|
|
||||||
* @returns A new read-only atom.
|
|
||||||
*/
|
|
||||||
function computed<State>(callback: Selector<State>, options?: AtomOptions<State>): Selector<State>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to changes in the given atom or selector. The callback is
|
|
||||||
* called with the current state and the previous state immediately after a
|
|
||||||
* change occurs.
|
|
||||||
*
|
|
||||||
* @param callback The atom or selector to subscribe to.
|
|
||||||
* @param listener The function to call when the state changes.
|
|
||||||
* @returns A function that unsubscribes the callback.
|
|
||||||
*/
|
|
||||||
function subscribe<State>(callback: Selector<State>, listener: (state: State, prev: State) => void): Cleanup;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the given callback immediately and whenever any atom it depends on
|
|
||||||
* changes. Returns a cleanup function that unsubscribes the callback.
|
|
||||||
*
|
|
||||||
* @param callback The function to run.
|
|
||||||
* @returns A function that unsubscribes the callback.
|
|
||||||
*/
|
|
||||||
function effect(callback: () => Cleanup | void): Cleanup;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the result of the function without subscribing to changes. If a
|
|
||||||
* non-function value is provided, it is returned as is.
|
|
||||||
*
|
|
||||||
* @param callback The atom or selector to get the state of.
|
|
||||||
* @param args Arguments to pass to the function.
|
|
||||||
* @returns The current state.
|
|
||||||
*/
|
|
||||||
function peek<State, Args extends unknown[]>(callback: State | ((...args: Args) => State), ...args: Args): State;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether the given value is an atom.
|
|
||||||
*
|
|
||||||
* @param value The value to check.
|
|
||||||
* @returns `true` if the value is an atom, otherwise `false`.
|
|
||||||
*/
|
|
||||||
function isAtom(value: unknown): value is Atom<any>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the given function and schedules listeners to be notified only once
|
|
||||||
* after the function has completed. Useful for batching multiple changes.
|
|
||||||
*
|
|
||||||
* @param callback The function to run.
|
|
||||||
*/
|
|
||||||
function batch(callback: () => void): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captures all atoms that are read during the function call and returns them
|
|
||||||
* along with the result of the function. Useful for tracking dependencies.
|
|
||||||
*
|
|
||||||
* @param callback The function to run.
|
|
||||||
* @returns A tuple containing the captured atoms and the result of the function.
|
|
||||||
*/
|
|
||||||
function capture<State>(callback: Selector<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifies all subscribers of the given atom that the state has changed.
|
|
||||||
* Mainly for running effects, since subscriptions check for equality.
|
|
||||||
*
|
|
||||||
* @param atom The atom to notify.
|
|
||||||
*/
|
|
||||||
function notify<State>(atom: Atom<State>): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an instance of `factory` for each item in the atom's state, and
|
|
||||||
* cleans up the instance when the item is removed. Returns a cleanup function
|
|
||||||
* that unsubscribes all instances.
|
|
||||||
*
|
|
||||||
* @param callback The atom or selector to observe.
|
|
||||||
* @param factory The function that tracks the lifecycle of each item.
|
|
||||||
* @returns A function that unsubscribes all instances.
|
|
||||||
*/
|
|
||||||
function observe<Item>(
|
|
||||||
callback: Selector<readonly Item[]>,
|
|
||||||
factory: (item: Item, index: number) => Cleanup | void,
|
|
||||||
): Cleanup;
|
|
||||||
|
|
||||||
function observe<Key, Item>(
|
|
||||||
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
|
|
||||||
factory: (item: Item, key: Key) => Cleanup | void,
|
|
||||||
): Cleanup;
|
|
||||||
|
|
||||||
function observe<Key extends string | number | symbol, Item>(
|
|
||||||
callback: Selector<{ readonly [P in Key]: Item }>,
|
|
||||||
factory: (item: Item, key: Key) => Cleanup | void,
|
|
||||||
): Cleanup;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
|
||||||
* function returns `undefined`, the entry is omitted from the resulting map.
|
|
||||||
* When the atom changes, the `mapper` is called for each entry in the state
|
|
||||||
* to compute the new state.
|
|
||||||
*
|
|
||||||
* @param callback The atom or selector to map.
|
|
||||||
* @param mapper The function that maps each entry.
|
|
||||||
* @returns A new atom with the mapped state.
|
|
||||||
*/
|
|
||||||
function mapped<V0, K1, V1>(
|
|
||||||
callback: Selector<readonly V0[]>,
|
|
||||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
|
||||||
): Selector<ReadonlyMap<K1, V1>>;
|
|
||||||
|
|
||||||
function mapped<V0, V1>(
|
|
||||||
callback: Selector<readonly V0[]>,
|
|
||||||
mapper: (value: V0, index: number) => V1,
|
|
||||||
): Selector<readonly V1[]>;
|
|
||||||
|
|
||||||
function mapped<K0, V0, K1 = K0, V1 = V0>(
|
|
||||||
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
|
|
||||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
|
||||||
): Selector<ReadonlyMap<K1, V1>>;
|
|
||||||
|
|
||||||
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
|
|
||||||
callback: Selector<{ readonly [P in K0]: V0 }>,
|
|
||||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
|
||||||
): Selector<ReadonlyMap<K1, V1>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReactiveNode {
|
||||||
|
deps?: Link;
|
||||||
|
depsTail?: Link;
|
||||||
|
subs?: Link;
|
||||||
|
subsTail?: Link;
|
||||||
|
flags: ReactiveFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
version: number;
|
||||||
|
dep: ReactiveNode;
|
||||||
|
sub: ReactiveNode;
|
||||||
|
prevSub?: Link;
|
||||||
|
nextSub?: Link;
|
||||||
|
prevDep?: Link;
|
||||||
|
nextDep?: Link;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Atom<T> {
|
||||||
|
(newValue: T | ((currentValue: T) => T)): T;
|
||||||
|
(): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
|
||||||
|
|
||||||
|
export type Equals<T> = (current: T, incoming: T) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global flags that modify the behavior of Charm's reactive system.
|
||||||
|
*/
|
||||||
|
export const flags: {
|
||||||
|
/**
|
||||||
|
* Enforces synchronous, non-yielding behavior in signals and effects.
|
||||||
|
* Also enables state validation in Charm Sync to catch sync errors early.
|
||||||
|
* Enabled if the optimization level is below 2, which is true in studio.
|
||||||
|
*/
|
||||||
|
strict: boolean;
|
||||||
|
/**
|
||||||
|
* Enforces immutability of tables by deep-freezing table values. Enabled
|
||||||
|
* if the optimization level is below 2, which is true in studio.
|
||||||
|
*/
|
||||||
|
frozen: boolean;
|
||||||
|
/**
|
||||||
|
* Whether parent effects should track inner effects and clean them up
|
||||||
|
* when the parent effect re-runs. This is usually desirable, but can be
|
||||||
|
* disabled to revert to the old behavior. Defaults to `true`.
|
||||||
|
*/
|
||||||
|
trackInnerEffects: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive signal that stores a value and notifies subscribers when
|
||||||
|
* the value changes. The signal provides both a getter and a setter.
|
||||||
|
*
|
||||||
|
* @param initialValue The initial value of the signal.
|
||||||
|
* @param equals A comparator function to determine if the signal's value has changed.
|
||||||
|
* @returns A tuple containing the getter and setter functions for the signal.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||||
|
*/
|
||||||
|
export function signal<T>(
|
||||||
|
initialValue: T,
|
||||||
|
equals?: Equals<T>,
|
||||||
|
): LuaTuple<[getter: () => 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.
|
||||||
|
* Calling the atom with an argument sets its value, while calling it with no
|
||||||
|
* arguments returns its current value.
|
||||||
|
*
|
||||||
|
* @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
|
||||||
|
* @param equals A comparator function to determine if the atom's value has changed.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
|
||||||
|
*/
|
||||||
|
export function atom<T>(initialValue: T, equals?: Equals<T>): Atom<T>;
|
||||||
|
export function atom<T>(): Atom<T | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new read-only signal that is computed based on the values of
|
||||||
|
* other signals. The computed signal's value is automatically updated when a
|
||||||
|
* change is detected in any of its dependencies.
|
||||||
|
*
|
||||||
|
* @param getter A function that produces the next value.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||||
|
*/
|
||||||
|
export function computed<T>(getter: (previousValue?: T) => T): () => T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an effect that runs a callback in response to signal state changes.
|
||||||
|
* An effect tracks which signals are accessed during its execution, and runs
|
||||||
|
* the callback when those signals change.
|
||||||
|
*
|
||||||
|
* The effect callback may return a cleanup function, which gets called once,
|
||||||
|
* either before the effect re-runs or when it is disposed.
|
||||||
|
*
|
||||||
|
* If the effect is called within another effect, it will be disposed when the
|
||||||
|
* parent effect re-runs.
|
||||||
|
*
|
||||||
|
* @param fn The function to run when dependencies change.
|
||||||
|
* @returns A function for disposing the effect.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
||||||
|
*/
|
||||||
|
export function effect(fn: () => Cleanup | void): Cleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an effect scope that can capture reactive effects and computed
|
||||||
|
* signals created within it so that they can be disposed together.
|
||||||
|
*
|
||||||
|
* @param fn A function that may create reactive effects.
|
||||||
|
* @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
|
||||||
|
* @returns A function for disposing all effects created within the scope.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
||||||
|
*/
|
||||||
|
export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows you to manually trigger updates for downstream dependencies when
|
||||||
|
* you've directly mutated a signal's value without using the signal setter.
|
||||||
|
*
|
||||||
|
* @param fn The dependency or a function that calls multiple dependencies.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
|
||||||
|
*/
|
||||||
|
export function trigger(fn: () => void): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the cleanup function to the current active effect or effect scope. If
|
||||||
|
* there is no active effect, a warning is logged, unless `failSilently` is
|
||||||
|
* set to `true`.
|
||||||
|
*
|
||||||
|
* @param fn The cleanup function to register.
|
||||||
|
* @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
|
||||||
|
*/
|
||||||
|
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the function without subscribing to signal updates or capturing inner
|
||||||
|
* effects in the parent effect or scope.
|
||||||
|
*
|
||||||
|
* For an alternative that still cleans up inner effects, use `peek` instead.
|
||||||
|
*
|
||||||
|
* @param fn A function that may read signals or create effects.
|
||||||
|
* @param args Arguments to pass to the function.
|
||||||
|
* @returns The return value of the function.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||||
|
*/
|
||||||
|
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* computed signals after the provided function finishes running.
|
||||||
|
*
|
||||||
|
* @param fn A function that performs multiple updates.
|
||||||
|
* @param args Arguments to pass to the function.
|
||||||
|
* @returns The return value of the function.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
|
||||||
|
*/
|
||||||
|
export function batch<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an effect that runs the callback one immediately, and then again
|
||||||
|
* when the value returned by the getter changes. The callback receives the new
|
||||||
|
* value and the previous value as arguments.
|
||||||
|
*
|
||||||
|
* 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 fn The function to run when the value changes.
|
||||||
|
* @returns A function for disposing the effect.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||||
|
*/
|
||||||
|
export function listen<T>(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an effect that only runs the callback when the value returned by the
|
||||||
|
* getter changes. The callback receives the new value and the previous value
|
||||||
|
* as arguments.
|
||||||
|
*
|
||||||
|
* 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 fn The function to run when the value changes.
|
||||||
|
* @returns A function for disposing the effect.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||||
|
*/
|
||||||
|
export function subscribe<T>(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new read-only signal that is computed by mapping over the entries
|
||||||
|
* of an existing atom. If the key is omitted from the mapper's return value,
|
||||||
|
* the original key is preserved.
|
||||||
|
*
|
||||||
|
* @param getter Returns the table to map over.
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
export function mapped<VI, KI, VO, KO = KI>(
|
||||||
|
getter: () => Map<KI, VI> | ReadonlyMap<KI, VI>,
|
||||||
|
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||||
|
): () => ReadonlyMap<KO, VO>;
|
||||||
|
// Overload for arrays
|
||||||
|
export function mapped<VI, VO, K extends Key = number>(
|
||||||
|
getter: () => readonly VI[],
|
||||||
|
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
|
||||||
|
): () => K extends number ? readonly VO[] : { readonly [P in K]: VO };
|
||||||
|
// Overload for objects
|
||||||
|
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
|
||||||
|
getter: () => { readonly [K in KI]: VI },
|
||||||
|
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
|
||||||
|
): () => { readonly [K in KO]: VO };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the callback function for each unique key in the table returned by the
|
||||||
|
* getter. The callback is called once for each key, and again when a new key
|
||||||
|
* is added.
|
||||||
|
*
|
||||||
|
* The callback may return a cleanup function, which will be called when the
|
||||||
|
* key is removed or when the entire callback is disposed.
|
||||||
|
*
|
||||||
|
* Effects created within the callback are cleaned up when the item is removed
|
||||||
|
* from the table or when the callback is disposed.
|
||||||
|
*
|
||||||
|
* @param getter A function that returns the table to observe.
|
||||||
|
* @param fn A function that is called for each key in the table.
|
||||||
|
* @returns A function for disposing the observer.
|
||||||
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||||
|
*/
|
||||||
|
export function observe<V, K = number>(
|
||||||
|
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
|
||||||
|
fn: (value: V, key: K) => Cleanup | void,
|
||||||
|
): Cleanup;
|
||||||
|
// Overload for objects
|
||||||
|
export function observe<V, K extends Key>(
|
||||||
|
getter: () => { readonly [K in Key]: V },
|
||||||
|
fn: (value: V, key: K) => Cleanup | void,
|
||||||
|
): Cleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables recursive checks for the current effect or computed signal.
|
||||||
|
* Useful for effects that intentionally update signals they depend on.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* effect(() => {
|
||||||
|
* recursive();
|
||||||
|
* setCount(getCount() + 1);
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function recursive(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, setUsers = reactive({
|
||||||
|
* user = { name = "John", surname = "Doe" },
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* effect(function()
|
||||||
|
* print(users.user and `{users.user.name} {users.user.surname}`)
|
||||||
|
* end)
|
||||||
|
*
|
||||||
|
* setUsers(function(state)
|
||||||
|
* state.user.name = "Jane"
|
||||||
|
* state.user.surname = "Smith"
|
||||||
|
* end)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param initialValue The value to make deeply reactive.
|
||||||
|
* @return A proxy table that tracks reads to nested properties.
|
||||||
|
* @return A function for updating 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, setter: (update: T | ((initialValue: T) => void)) => T]>;
|
||||||
|
|
||||||
|
export function getActiveSub(): ReactiveNode | undefined;
|
||||||
|
|
||||||
|
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||||
|
|
||||||
|
export function startBatch(): void;
|
||||||
|
|
||||||
|
export function endBatch(): void;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,957 @@
|
||||||
local atom = require(script.atom)
|
local system = require("@self/system")
|
||||||
local computed = require(script.computed)
|
|
||||||
local effect = require(script.effect)
|
|
||||||
local mapped = require(script.mapped)
|
|
||||||
local observe = require(script.observe)
|
|
||||||
local store = require(script.store)
|
|
||||||
local subscribe = require(script.subscribe)
|
|
||||||
local types = require(script.types)
|
|
||||||
|
|
||||||
export type Atom<State> = types.Atom<State>
|
export type ReactiveFlags = system.ReactiveFlags
|
||||||
export type Selector<State> = types.Selector<State>
|
export type ReactiveNode = system.ReactiveNode
|
||||||
export type Molecule<State> = types.Selector<State>
|
export type Link = system.Link
|
||||||
export type SyncPayload = types.SyncPayload
|
|
||||||
|
export type Atom<T = any> = (...((T) -> T) | T) -> T
|
||||||
|
|
||||||
|
type Target = { [any]: any }
|
||||||
|
|
||||||
|
type Proxy = {
|
||||||
|
__target: Target,
|
||||||
|
__signals: { [any]: () -> any },
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectScopeNode = ReactiveNode & {
|
||||||
|
cleanups: { () -> () },
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectNode = ReactiveNode & {
|
||||||
|
fn: () -> ...(() -> ())?,
|
||||||
|
cleanups: { () -> () },
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComputedNode<T = any> = ReactiveNode & {
|
||||||
|
value: T?,
|
||||||
|
getter: (previousValue: T?) -> T,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalNode<T = any> = ReactiveNode & {
|
||||||
|
currentValue: T,
|
||||||
|
pendingValue: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
local NONE = 0b0000000
|
||||||
|
local MUTABLE = 0b0000001
|
||||||
|
local WATCHING = 0b0000010
|
||||||
|
local RECURSED_CHECK = 0b0000100
|
||||||
|
local DIRTY = 0b0010000
|
||||||
|
local PENDING = 0b0100000
|
||||||
|
local PEEKING = 0b1000000
|
||||||
|
|
||||||
|
local function inlineTest(): string
|
||||||
|
return debug.info(1, "n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local flags = {
|
||||||
|
--[[
|
||||||
|
Enforces synchronous, non-yielding behavior in signals and effects.
|
||||||
|
Also enables state validation in Charm Sync to catch sync errors early.
|
||||||
|
Enabled if the optimization level is below 2, which is true in studio.
|
||||||
|
]]
|
||||||
|
strict = inlineTest() == "inlineTest",
|
||||||
|
--[[
|
||||||
|
Enforces immutability of tables by deep-freezing table values. Enabled
|
||||||
|
if the optimization level is below 2, which is true in studio.
|
||||||
|
]]
|
||||||
|
frozen = inlineTest() == "inlineTest",
|
||||||
|
--[[
|
||||||
|
Whether parent effects should track inner effects and clean them up
|
||||||
|
when the parent effect re-runs. This is usually desirable, but can be
|
||||||
|
disabled to revert to the old behavior. Defaults to `true`.
|
||||||
|
]]
|
||||||
|
trackInnerEffects = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
|
||||||
|
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
|
||||||
|
local propagate: (link: Link) -> ()
|
||||||
|
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
|
||||||
|
local shallowPropagate: (link: Link) -> ()
|
||||||
|
|
||||||
|
local cycle = 0
|
||||||
|
local batchDepth = 0
|
||||||
|
local notifyIndex = 0
|
||||||
|
local queued: { EffectNode? } = {}
|
||||||
|
local queuedLength = 0
|
||||||
|
local activeSub: ReactiveNode?
|
||||||
|
|
||||||
|
local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results...
|
||||||
|
if not flags.strict then
|
||||||
|
return fn
|
||||||
|
end
|
||||||
|
local function resultHandler(thread: thread, ok: boolean, ...)
|
||||||
|
if not ok then
|
||||||
|
error(debug.traceback(thread, tostring((...))))
|
||||||
|
elseif coroutine.status(thread) ~= "dead" then
|
||||||
|
error(debug.traceback(thread, "Yielding is not allowed in strict mode"))
|
||||||
|
end
|
||||||
|
return ...
|
||||||
|
end
|
||||||
|
return function(...)
|
||||||
|
local thread = coroutine.create(fn)
|
||||||
|
return resultHandler(thread, coroutine.resume(thread, ...))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deepFreeze(object: any)
|
||||||
|
if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then
|
||||||
|
table.freeze(object)
|
||||||
|
for _, value in object do
|
||||||
|
deepFreeze(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getActiveSub(): ReactiveNode?
|
||||||
|
return activeSub
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setActiveSub(sub: ReactiveNode?)
|
||||||
|
local prevSub = activeSub
|
||||||
|
activeSub = sub
|
||||||
|
return prevSub
|
||||||
|
end
|
||||||
|
|
||||||
|
local function purgeDeps(sub: ReactiveNode)
|
||||||
|
local depsTail = sub.depsTail
|
||||||
|
local dep = if depsTail then depsTail.nextDep else sub.deps
|
||||||
|
while dep do
|
||||||
|
dep = unlink(dep, sub)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runCleanups(effect: EffectScopeNode)
|
||||||
|
local cleanups = effect.cleanups
|
||||||
|
for key, cleanup in cleanups do
|
||||||
|
local prevSub = setActiveSub(nil)
|
||||||
|
local success, result: any = pcall(cleanup)
|
||||||
|
activeSub = prevSub
|
||||||
|
cleanups[key] = nil
|
||||||
|
if not success then
|
||||||
|
warn("An error occurred during effect cleanup:", result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run(e: EffectNode): ()
|
||||||
|
local flags = e.flags
|
||||||
|
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then
|
||||||
|
runCleanups(e)
|
||||||
|
cycle += 1
|
||||||
|
e.depsTail = nil
|
||||||
|
e.flags = bit32.bor(WATCHING, RECURSED_CHECK)
|
||||||
|
local prevSub = setActiveSub(e)
|
||||||
|
local success, result = pcall(e.fn)
|
||||||
|
activeSub = prevSub
|
||||||
|
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
|
purgeDeps(e)
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
elseif result then
|
||||||
|
table.insert(e.cleanups, noYield(result))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
e.flags = WATCHING
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush(): ()
|
||||||
|
while notifyIndex < queuedLength do
|
||||||
|
local effect = queued[notifyIndex] :: EffectNode
|
||||||
|
queued[notifyIndex] = nil
|
||||||
|
notifyIndex += 1
|
||||||
|
run(effect)
|
||||||
|
end
|
||||||
|
notifyIndex = 0
|
||||||
|
queuedLength = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function startBatch()
|
||||||
|
batchDepth += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function endBatch()
|
||||||
|
batchDepth -= 1
|
||||||
|
if batchDepth == 0 then
|
||||||
|
flush()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function updateComputed(c: ComputedNode): boolean
|
||||||
|
cycle += 1
|
||||||
|
c.depsTail = nil
|
||||||
|
c.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
|
||||||
|
local prevSub = setActiveSub(c)
|
||||||
|
local oldValue = c.value
|
||||||
|
local success, result = pcall(c.getter, oldValue)
|
||||||
|
activeSub = prevSub
|
||||||
|
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
|
purgeDeps(c)
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
end
|
||||||
|
if oldValue ~= result then
|
||||||
|
c.value = result
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function updateSignal(s: SignalNode): boolean
|
||||||
|
s.flags = MUTABLE
|
||||||
|
local pendingValue = s.pendingValue
|
||||||
|
if s.currentValue ~= pendingValue then
|
||||||
|
s.currentValue = pendingValue
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags)
|
||||||
|
sub.flags = bit32.band(flags, bit32.bnot(PENDING))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function computedOper<T>(this: ComputedNode<T>): () -> T
|
||||||
|
local function computedGetter(): T
|
||||||
|
local flags = this.flags
|
||||||
|
if
|
||||||
|
bit32.btest(flags, DIRTY)
|
||||||
|
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags)))
|
||||||
|
then
|
||||||
|
if updateComputed(this) then
|
||||||
|
local subs = this.subs
|
||||||
|
if subs then
|
||||||
|
shallowPropagate(subs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif flags == NONE then
|
||||||
|
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
|
||||||
|
local prevSub = setActiveSub(this)
|
||||||
|
local success, result = pcall(this.getter)
|
||||||
|
activeSub = prevSub
|
||||||
|
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
|
if success then
|
||||||
|
this.value = result
|
||||||
|
else
|
||||||
|
error(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if activeSub then
|
||||||
|
link(this, activeSub, cycle)
|
||||||
|
end
|
||||||
|
return this.value :: T
|
||||||
|
end
|
||||||
|
return computedGetter
|
||||||
|
end
|
||||||
|
|
||||||
|
local function signalOper<T>(
|
||||||
|
this: SignalNode<T>,
|
||||||
|
equals: ((current: T, incoming: T) -> boolean)?
|
||||||
|
): (() -> T, (((T) -> T) | T) -> T)
|
||||||
|
local function signalSetter(update: ((T) -> T) | T): T
|
||||||
|
local value: T
|
||||||
|
if type(update) == "function" then
|
||||||
|
local prevSub = setActiveSub(nil)
|
||||||
|
local success, result = pcall(noYield(update), this.pendingValue)
|
||||||
|
activeSub = prevSub
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
end
|
||||||
|
value = result
|
||||||
|
else
|
||||||
|
value = update
|
||||||
|
end
|
||||||
|
if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then
|
||||||
|
if flags.frozen then
|
||||||
|
deepFreeze(value)
|
||||||
|
end
|
||||||
|
this.pendingValue = value
|
||||||
|
this.flags = bit32.bor(MUTABLE, DIRTY)
|
||||||
|
local subs = this.subs
|
||||||
|
if subs then
|
||||||
|
propagate(subs)
|
||||||
|
if batchDepth == 0 then
|
||||||
|
flush()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return this.currentValue
|
||||||
|
end
|
||||||
|
|
||||||
|
local function signalGetter(): T
|
||||||
|
if bit32.btest(this.flags, DIRTY) then
|
||||||
|
if updateSignal(this) then
|
||||||
|
local subs = this.subs
|
||||||
|
if subs then
|
||||||
|
shallowPropagate(subs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local sub = activeSub
|
||||||
|
while sub do
|
||||||
|
if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then
|
||||||
|
link(this, sub, cycle)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local subs = sub.subs
|
||||||
|
sub = if subs then subs.sub else nil
|
||||||
|
end
|
||||||
|
return this.currentValue
|
||||||
|
end
|
||||||
|
|
||||||
|
return signalGetter, signalSetter
|
||||||
|
end
|
||||||
|
|
||||||
|
local function effectOper(this: EffectScopeNode): () -> ()
|
||||||
|
return function()
|
||||||
|
this.depsTail = nil
|
||||||
|
this.flags = NONE
|
||||||
|
purgeDeps(this)
|
||||||
|
local sub = this.subs
|
||||||
|
if sub then
|
||||||
|
unlink(sub, this)
|
||||||
|
end
|
||||||
|
runCleanups(this)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates a reactive signal that stores a value and notifies subscribers when
|
||||||
|
the value changes. The signal provides both a getter and a setter.
|
||||||
|
|
||||||
|
@param initialValue The initial value of the signal.
|
||||||
|
@param equals A comparator function to determine if the signal's value has changed.
|
||||||
|
@return A function to get the current value of the signal.
|
||||||
|
@return A function to set a new value and notify subscribers.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
|
||||||
|
]]
|
||||||
|
local function signal<T>(
|
||||||
|
initialValue: T,
|
||||||
|
equals: ((current: T, incoming: T) -> boolean)?
|
||||||
|
): (() -> T, (((T) -> T) | T) -> T)
|
||||||
|
if flags.frozen then
|
||||||
|
deepFreeze(initialValue)
|
||||||
|
end
|
||||||
|
return signalOper({
|
||||||
|
currentValue = initialValue,
|
||||||
|
pendingValue = initialValue,
|
||||||
|
flags = MUTABLE,
|
||||||
|
}, equals)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates a reactive atom that acts as both a signal getter and setter.
|
||||||
|
Calling the atom with an argument sets its value, while calling it with no
|
||||||
|
arguments returns its current value.
|
||||||
|
|
||||||
|
@param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
|
||||||
|
@param equals A comparator function to determine if the atom's value has changed.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
|
||||||
|
]]
|
||||||
|
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom<T>
|
||||||
|
local getter, setter = signal(initialValue, equals)
|
||||||
|
local function atomOper(...)
|
||||||
|
return if select("#", ...) == 0 then getter() else setter((...))
|
||||||
|
end
|
||||||
|
return atomOper
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates a new read-only signal that is computed based on the values of
|
||||||
|
other signals. The computed signal's value is automatically updated when a
|
||||||
|
change is detected in any of its dependencies.
|
||||||
|
|
||||||
|
@param getter A function that produces the next value.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||||
|
]]
|
||||||
|
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
|
||||||
|
return computedOper({
|
||||||
|
flags = NONE,
|
||||||
|
getter = noYield(getter),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates an effect that runs a callback in response to signal state changes.
|
||||||
|
An effect tracks which signals are accessed during its execution, and runs
|
||||||
|
the callback when those signals change.
|
||||||
|
|
||||||
|
The effect callback may return a cleanup function, which gets called once,
|
||||||
|
either before the effect re-runs or when it is disposed.
|
||||||
|
|
||||||
|
If the effect is called within another effect, it will be disposed when the
|
||||||
|
parent effect re-runs.
|
||||||
|
|
||||||
|
@param fn The function to run when dependencies change.
|
||||||
|
@return A function for disposing the effect.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
||||||
|
]]
|
||||||
|
local function effect(fn: () -> ...(() -> ())?): () -> ()
|
||||||
|
local e: EffectNode = {
|
||||||
|
fn = noYield(fn),
|
||||||
|
flags = bit32.bor(WATCHING, RECURSED_CHECK),
|
||||||
|
cleanups = {},
|
||||||
|
}
|
||||||
|
local prevSub = setActiveSub(e)
|
||||||
|
if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then
|
||||||
|
link(e, prevSub, 0)
|
||||||
|
end
|
||||||
|
local success, result = pcall(e.fn)
|
||||||
|
activeSub = prevSub
|
||||||
|
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
elseif result then
|
||||||
|
table.insert(e.cleanups, noYield(result))
|
||||||
|
end
|
||||||
|
return effectOper(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates an effect scope that can capture reactive effects and computed
|
||||||
|
signals created within it so that they can be disposed together.
|
||||||
|
|
||||||
|
@param fn A function that may create reactive effects.
|
||||||
|
@param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
|
||||||
|
@return A function for disposing all effects created within the scope.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
||||||
|
]]
|
||||||
|
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
|
||||||
|
local e: EffectScopeNode = {
|
||||||
|
flags = NONE,
|
||||||
|
cleanups = {},
|
||||||
|
}
|
||||||
|
local prevSub = setActiveSub(e)
|
||||||
|
if prevSub and not detached then
|
||||||
|
link(e, prevSub, 0)
|
||||||
|
end
|
||||||
|
local success, result = pcall(noYield(fn))
|
||||||
|
activeSub = prevSub
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
elseif result then
|
||||||
|
table.insert(e.cleanups, noYield(result))
|
||||||
|
end
|
||||||
|
return effectOper(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Allows you to manually trigger updates for downstream dependencies when
|
||||||
|
you've directly mutated a signal's value without using the signal setter.
|
||||||
|
|
||||||
|
@param fn The dependency or a function that calls multiple dependencies.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
|
||||||
|
]]
|
||||||
|
local function trigger(fn: () -> ())
|
||||||
|
local sub: ReactiveNode = {
|
||||||
|
flags = WATCHING,
|
||||||
|
}
|
||||||
|
local prevSub = setActiveSub(sub)
|
||||||
|
local success, result: any = pcall(noYield(fn))
|
||||||
|
activeSub = prevSub
|
||||||
|
while sub.deps do
|
||||||
|
local link = sub.deps :: Link
|
||||||
|
local dep = link.dep
|
||||||
|
unlink(link, sub)
|
||||||
|
if dep.subs then
|
||||||
|
propagate(dep.subs)
|
||||||
|
shallowPropagate(dep.subs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if batchDepth == 0 then
|
||||||
|
flush()
|
||||||
|
end
|
||||||
|
if not success then
|
||||||
|
error(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Binds the cleanup function to the current active effect or effect scope. If
|
||||||
|
there is no active effect, a warning is logged, unless `failSilently` is
|
||||||
|
set to `true`.
|
||||||
|
|
||||||
|
@param fn The cleanup function to register.
|
||||||
|
@param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
|
||||||
|
]]
|
||||||
|
local function onCleanup(fn: () -> (), failSilently: boolean?)
|
||||||
|
if activeSub and (activeSub :: EffectNode).cleanups then
|
||||||
|
table.insert((activeSub :: EffectNode).cleanups, noYield(fn))
|
||||||
|
elseif not failSilently then
|
||||||
|
warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Runs the function without subscribing to signal updates or capturing inner
|
||||||
|
effects in the parent effect or scope.
|
||||||
|
|
||||||
|
For an alternative that still cleans up inner effects, use `peek` instead.
|
||||||
|
|
||||||
|
@param fn A function that may read signals or create effects.
|
||||||
|
@param ...args Arguments to pass to the function.
|
||||||
|
@return The return value of the function.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||||
|
]]
|
||||||
|
local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
|
||||||
|
if activeSub then
|
||||||
|
local prevSub = setActiveSub(nil)
|
||||||
|
local results: { any } = { pcall(noYield(fn), ...) }
|
||||||
|
activeSub = prevSub
|
||||||
|
if not results[1] then
|
||||||
|
error(results[2])
|
||||||
|
end
|
||||||
|
return unpack(results, 2)
|
||||||
|
else
|
||||||
|
return fn(...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
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.
|
||||||
|
@return The return value of the function.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
|
||||||
|
]]
|
||||||
|
local function peek<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
|
||||||
|
if
|
||||||
|
activeSub
|
||||||
|
and bit32.band(activeSub.flags, PEEKING) == 0
|
||||||
|
and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE))
|
||||||
|
then
|
||||||
|
activeSub.flags = bit32.bor(activeSub.flags, PEEKING)
|
||||||
|
local results: { any } = { pcall(noYield(fn), ...) }
|
||||||
|
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING))
|
||||||
|
if not results[1] then
|
||||||
|
error(results[2])
|
||||||
|
end
|
||||||
|
return unpack(results, 2)
|
||||||
|
else
|
||||||
|
return fn(...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Combines multiple updates into a single "commit" that runs effects and
|
||||||
|
computed signals after the provided callback finishes running.
|
||||||
|
|
||||||
|
@param fn A function that performs multiple updates.
|
||||||
|
@param ...args Arguments to pass to the function.
|
||||||
|
@return The return value of the function.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
|
||||||
|
]]
|
||||||
|
local function batch<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
|
||||||
|
startBatch()
|
||||||
|
local results: { any } = { pcall(noYield(fn), ...) }
|
||||||
|
endBatch()
|
||||||
|
if not results[1] then
|
||||||
|
error(results[2])
|
||||||
|
end
|
||||||
|
return unpack(results, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Converts a non-signal into a computed signal. If the function is already a
|
||||||
|
signal, it is returned as-is. Useful for memoizing a getter function
|
||||||
|
without creating unnecessary nested signals.
|
||||||
|
|
||||||
|
@param fn A function that returns the value to track.
|
||||||
|
@return A computed signal that tracks the value returned by the getter.
|
||||||
|
]]
|
||||||
|
local function toSignal<T>(fn: () -> T): () -> T
|
||||||
|
local name = debug.info(fn, "n")
|
||||||
|
return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates an effect that runs the callback one immediately, and then again
|
||||||
|
when the value returned by the getter changes. The callback receives the new
|
||||||
|
value and the previous value as arguments.
|
||||||
|
|
||||||
|
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 fn The function to run when the value changes.
|
||||||
|
@return A function for disposing the effect.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
||||||
|
]]
|
||||||
|
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> ()
|
||||||
|
local tracked = toSignal(getter)
|
||||||
|
local current: T
|
||||||
|
return effect(function()
|
||||||
|
local previous = current
|
||||||
|
current = tracked()
|
||||||
|
peek(fn, current, previous)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates an effect that only runs the callback when the value returned by the
|
||||||
|
getter changes. The callback receives the new value and the previous value
|
||||||
|
as arguments.
|
||||||
|
|
||||||
|
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 fn The function to run when the value changes.
|
||||||
|
@return A function for disposing the effect.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
||||||
|
]]
|
||||||
|
local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> ()
|
||||||
|
local tracked = toSignal(getter)
|
||||||
|
local current: T
|
||||||
|
local firstRun = true
|
||||||
|
return effect(function()
|
||||||
|
local previous = current
|
||||||
|
current = tracked()
|
||||||
|
if firstRun then
|
||||||
|
firstRun = false
|
||||||
|
else
|
||||||
|
peek(fn, current, previous)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Disables recursive checks for the current effect or computed signal. Useful
|
||||||
|
for effects that intentionally update signals they depend on.
|
||||||
|
|
||||||
|
@example
|
||||||
|
```luau
|
||||||
|
effect(function()
|
||||||
|
recursive()
|
||||||
|
setCount(getCount() + 1)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
]]
|
||||||
|
local function recursive()
|
||||||
|
if activeSub then
|
||||||
|
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
|
else
|
||||||
|
warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Calls the callback function for each unique key in the table returned by the
|
||||||
|
getter. The callback is called once for each key, and again when a new key
|
||||||
|
is added.
|
||||||
|
|
||||||
|
The callback may return a cleanup function, which will be called when the
|
||||||
|
key is removed or the `observe()` effect is disposed. Effects created
|
||||||
|
during the callback will also be bound to the lifetime of the key.
|
||||||
|
|
||||||
|
@param getter A function that returns the table to observe.
|
||||||
|
@param fn A function that is called for each key in the table.
|
||||||
|
@return A function for disposing the observer.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||||
|
]]
|
||||||
|
local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> ()
|
||||||
|
local innerScopes: { [K]: () -> () } = {}
|
||||||
|
local disposed = false
|
||||||
|
|
||||||
|
local function updateScopes(data: { [K]: V })
|
||||||
|
for key, dispose in innerScopes do
|
||||||
|
if data[key] == nil then
|
||||||
|
innerScopes[key] = nil
|
||||||
|
dispose()
|
||||||
|
if disposed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for key, value in data do
|
||||||
|
if innerScopes[key] == nil then
|
||||||
|
local disposeScope = effectScope(function()
|
||||||
|
return fn(value, key)
|
||||||
|
end, true)
|
||||||
|
if disposed then
|
||||||
|
return disposeScope()
|
||||||
|
end
|
||||||
|
innerScopes[key] = disposeScope
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return effectScope(function()
|
||||||
|
effect(function()
|
||||||
|
-- Allow recursive updates in case the callback creates or removes
|
||||||
|
-- items in the observed table.
|
||||||
|
recursive()
|
||||||
|
-- Batch so that side effects run after all scopes are created.
|
||||||
|
-- This ensures that a scope creating or removing items doesn't
|
||||||
|
-- interfere with the current update cycle.
|
||||||
|
batch(updateScopes, getter())
|
||||||
|
end)
|
||||||
|
return function()
|
||||||
|
if disposed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
disposed = true
|
||||||
|
for key, dispose in innerScopes do
|
||||||
|
innerScopes[key] = nil
|
||||||
|
dispose()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates a new read-only signal that is computed by mapping over the entries
|
||||||
|
of an existing atom. If the key is omitted from the mapper's return value,
|
||||||
|
the original key is preserved.
|
||||||
|
|
||||||
|
@param getter Returns the table to map over.
|
||||||
|
@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
|
||||||
|
]]
|
||||||
|
local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
|
||||||
|
local currentInput: { [KI]: VI } = {}
|
||||||
|
local keyCache: { [KI]: KO } = {}
|
||||||
|
|
||||||
|
local function map(value: VI, key: KI): (VO, KO)
|
||||||
|
local prevSub = setActiveSub(nil)
|
||||||
|
local success, valueOut, keyOut = pcall(mapper, value, key)
|
||||||
|
activeSub = prevSub
|
||||||
|
if not success then
|
||||||
|
error(`mapped() transform function failed: {valueOut}`)
|
||||||
|
end
|
||||||
|
return valueOut, if keyOut == nil then key else keyOut :: any
|
||||||
|
end
|
||||||
|
|
||||||
|
return computed(function(previousOutput)
|
||||||
|
local currentOutput: { [KO]: VO } = previousOutput or {}
|
||||||
|
local previousInput = currentInput
|
||||||
|
currentInput = getter()
|
||||||
|
|
||||||
|
for keyIn in previousInput do
|
||||||
|
if currentInput[keyIn] ~= nil then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local prevKeyOut = keyCache[keyIn]
|
||||||
|
|
||||||
|
if prevKeyOut then
|
||||||
|
if currentOutput == previousOutput then
|
||||||
|
currentOutput = table.clone(currentOutput)
|
||||||
|
end
|
||||||
|
currentOutput[prevKeyOut] = nil
|
||||||
|
keyCache[keyIn] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for keyIn, valueIn in currentInput do
|
||||||
|
if valueIn == previousInput[keyIn] then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local valueOut, keyOut = map(valueIn, keyIn)
|
||||||
|
|
||||||
|
if currentOutput[keyOut] ~= valueOut then
|
||||||
|
if currentOutput == previousOutput then
|
||||||
|
currentOutput = table.clone(currentOutput)
|
||||||
|
end
|
||||||
|
currentOutput[keyOut] = valueOut
|
||||||
|
keyCache[keyIn] = keyOut
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return currentOutput
|
||||||
|
end)
|
||||||
|
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, setUsers = reactive({
|
||||||
|
user = { name = "John", surname = "Doe" },
|
||||||
|
})
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
print(users.user and `{users.user.name} {users.user.surname}`)
|
||||||
|
end)
|
||||||
|
|
||||||
|
setUsers(function(state)
|
||||||
|
state.user.name = "Jane"
|
||||||
|
state.user.surname = "Smith"
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
@param initialValue The value to make deeply reactive.
|
||||||
|
@return A proxy table that tracks reads to nested properties.
|
||||||
|
@return A function for updating the original table.
|
||||||
|
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
|
||||||
|
]]
|
||||||
|
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ())
|
||||||
|
local track = signal(nil)
|
||||||
|
|
||||||
|
local function setValue(update: ((T) -> ()) | T?)
|
||||||
|
if type(update) == "function" then
|
||||||
|
update(initialValue)
|
||||||
|
elseif type(update) == "table" then
|
||||||
|
table.clear(initialValue)
|
||||||
|
for key, value in update do
|
||||||
|
(initialValue :: Target)[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
trigger(track)
|
||||||
|
end
|
||||||
|
|
||||||
|
local proxyMeta = {
|
||||||
|
initialValue = initialValue,
|
||||||
|
track = track,
|
||||||
|
setValue = setValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" })
|
||||||
|
|
||||||
|
local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any)
|
||||||
|
if not cache[key] then
|
||||||
|
cache[key] = untracked(computed, function()
|
||||||
|
track()
|
||||||
|
return target[key]
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
cache[key]()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createProxy(target: Target): Proxy
|
||||||
|
if not proxyMap[target] then
|
||||||
|
proxyMap[target] = setmetatable({
|
||||||
|
__target = target,
|
||||||
|
__signals = setmetatable({}, { __mode = "v" }),
|
||||||
|
}, proxyMeta)
|
||||||
|
end
|
||||||
|
return proxyMap[target]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function nextProxy(target: Target, index: any)
|
||||||
|
local nextKey, nextValue = next(target, index)
|
||||||
|
return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue
|
||||||
|
end
|
||||||
|
|
||||||
|
function proxyMeta.__index(self: Proxy, key: any): any
|
||||||
|
if activeSub then
|
||||||
|
trackProperty(self.__signals, self.__target, key)
|
||||||
|
end
|
||||||
|
local value = self.__target[key]
|
||||||
|
return if type(value) == "table" then createProxy(value) else value
|
||||||
|
end
|
||||||
|
|
||||||
|
function proxyMeta.__newindex(self: Proxy, key: any, value: any)
|
||||||
|
if type(value) == "table" and getmetatable(value) == proxyMeta then
|
||||||
|
value = value.__target
|
||||||
|
end
|
||||||
|
if self.__target[key] ~= value then
|
||||||
|
self.__target[key] = value
|
||||||
|
trigger(track)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function proxyMeta.__iter(self: Proxy)
|
||||||
|
track()
|
||||||
|
return nextProxy, self.__target
|
||||||
|
end
|
||||||
|
|
||||||
|
function proxyMeta.__len(self: Proxy)
|
||||||
|
track()
|
||||||
|
return #self.__target
|
||||||
|
end
|
||||||
|
|
||||||
|
return createProxy(initialValue) :: any, setValue
|
||||||
|
end
|
||||||
|
|
||||||
|
local reactiveSystem = system.createReactiveSystem({
|
||||||
|
update = function(node)
|
||||||
|
if node.depsTail then
|
||||||
|
return updateComputed(node :: ComputedNode)
|
||||||
|
else
|
||||||
|
return updateSignal(node :: SignalNode)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
notify = function(effect)
|
||||||
|
local insertIndex = queuedLength
|
||||||
|
local firstInsertedIndex = insertIndex
|
||||||
|
|
||||||
|
repeat
|
||||||
|
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
|
||||||
|
queued[insertIndex] = effect :: EffectNode
|
||||||
|
insertIndex += 1
|
||||||
|
effect = (effect.subs and effect.subs.sub) :: EffectNode
|
||||||
|
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
until false
|
||||||
|
|
||||||
|
queuedLength = insertIndex
|
||||||
|
|
||||||
|
insertIndex -= 1
|
||||||
|
while firstInsertedIndex < insertIndex do
|
||||||
|
local left = queued[firstInsertedIndex]
|
||||||
|
queued[firstInsertedIndex] = queued[insertIndex]
|
||||||
|
queued[insertIndex] = left
|
||||||
|
firstInsertedIndex += 1
|
||||||
|
insertIndex -= 1
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
unwatched = function(node)
|
||||||
|
if bit32.band(node.flags, MUTABLE) == 0 then
|
||||||
|
effectOper(node :: EffectScopeNode)()
|
||||||
|
elseif node.depsTail then
|
||||||
|
node.depsTail = nil
|
||||||
|
node.flags = bit32.bor(MUTABLE, DIRTY)
|
||||||
|
purgeDeps(node)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
link = reactiveSystem.link
|
||||||
|
unlink = reactiveSystem.unlink
|
||||||
|
propagate = reactiveSystem.propagate
|
||||||
|
checkDirty = reactiveSystem.checkDirty
|
||||||
|
shallowPropagate = reactiveSystem.shallowPropagate
|
||||||
|
|
||||||
return {
|
return {
|
||||||
atom = atom,
|
ReactiveFlags = system.ReactiveFlags,
|
||||||
|
flags = flags,
|
||||||
|
getActiveSub = getActiveSub,
|
||||||
|
setActiveSub = setActiveSub,
|
||||||
|
startBatch = startBatch,
|
||||||
|
endBatch = endBatch,
|
||||||
|
signal = signal,
|
||||||
computed = computed,
|
computed = computed,
|
||||||
effect = effect,
|
effect = effect,
|
||||||
mapped = mapped,
|
effectScope = effectScope,
|
||||||
observe = observe,
|
trigger = trigger,
|
||||||
|
atom = atom,
|
||||||
|
onCleanup = onCleanup,
|
||||||
|
untracked = untracked,
|
||||||
|
peek = peek,
|
||||||
|
batch = batch,
|
||||||
subscribe = subscribe,
|
subscribe = subscribe,
|
||||||
batch = store.batch,
|
listen = listen,
|
||||||
capture = store.capture,
|
recursive = recursive,
|
||||||
isAtom = store.isAtom,
|
observe = observe,
|
||||||
notify = store.notify,
|
mapped = mapped :: (<KI, VI, KO, VO>(
|
||||||
peek = store.peek,
|
getter: () -> { [KI]: VI },
|
||||||
|
mapper: (item: VI, key: KI) -> (VO, KO)
|
||||||
|
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
|
||||||
|
reactive = reactive,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
local atom = require(script.Parent.atom)
|
|
||||||
local store = require(script.Parent.store)
|
|
||||||
local subscribe = require(script.Parent.subscribe)
|
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type Atom<T> = types.Atom<T>
|
|
||||||
type Selector<T> = types.Selector<T>
|
|
||||||
|
|
||||||
type Map =
|
|
||||||
(<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1)) -> Selector<{ [K1]: V1 }>)
|
|
||||||
& (<K0, V0, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Selector<{ [K0]: V1 }>)
|
|
||||||
& (<K0, V0, K1, V1>(fn: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Selector<{ [K1]: V1 }>)
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
|
||||||
function returns `undefined`, the entry is omitted from the resulting map.
|
|
||||||
When the atom changes, the `mapper` is called for each entry in the state
|
|
||||||
to compute the new state.
|
|
||||||
|
|
||||||
@param callback The atom or selector to map.
|
|
||||||
@param mapper The function that maps each entry.
|
|
||||||
@return A new atom with the mapped state.
|
|
||||||
]=]
|
|
||||||
local function mapped<K0, V0, K1, V1>(callback: Selector<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Selector<{ [K1]: V1 }>
|
|
||||||
local mappedAtom = atom({})
|
|
||||||
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
|
|
||||||
local prevMappedItems: { [K1]: V1 } = {}
|
|
||||||
local unsubscribe
|
|
||||||
|
|
||||||
local function listener(items: { [K0]: V0 })
|
|
||||||
local mappedAtom = mappedAtomRef.current
|
|
||||||
|
|
||||||
if not mappedAtom then
|
|
||||||
return unsubscribe()
|
|
||||||
end
|
|
||||||
|
|
||||||
local mappedItems = table.clone(mappedAtom())
|
|
||||||
local mappedKeys = {}
|
|
||||||
|
|
||||||
-- TODO: Only call mapper if the item has changed.
|
|
||||||
for key, item in next, items do
|
|
||||||
local newItem, newKey = mapper(item, key)
|
|
||||||
if newKey == nil then
|
|
||||||
newKey = key :: any
|
|
||||||
end
|
|
||||||
if mappedItems[newKey :: K1] ~= newItem then
|
|
||||||
mappedItems[newKey :: K1] = newItem :: V1
|
|
||||||
else
|
|
||||||
mappedKeys[newKey] = key
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for key in next, prevMappedItems do
|
|
||||||
if mappedKeys[key] == nil and mappedItems[key] == prevMappedItems[key] then
|
|
||||||
mappedItems[key] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
prevMappedItems = mappedItems
|
|
||||||
mappedAtom(mappedItems)
|
|
||||||
end
|
|
||||||
|
|
||||||
unsubscribe = subscribe(callback, listener)
|
|
||||||
|
|
||||||
store.peek(function(): ()
|
|
||||||
listener(callback())
|
|
||||||
end)
|
|
||||||
|
|
||||||
return mappedAtom
|
|
||||||
end
|
|
||||||
|
|
||||||
return mapped :: Map
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
local store = require(script.Parent.store)
|
|
||||||
local subscribe = require(script.Parent.subscribe)
|
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type Selector<T> = types.Selector<T>
|
|
||||||
|
|
||||||
local function noop() end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Creates an instance of `factory` for each item in the atom's state, and
|
|
||||||
cleans up the instance when the item is removed. Returns a cleanup function
|
|
||||||
that unsubscribes all instances.
|
|
||||||
|
|
||||||
@param callback The atom or selector to observe.
|
|
||||||
@param factory The function that tracks the lifecycle of each item.
|
|
||||||
@return A function that unsubscribes all instances.
|
|
||||||
]=]
|
|
||||||
local function observe<K, V>(callback: Selector<{ [K]: V }>, factory: (value: V, key: K) -> (() -> ())?): () -> ()
|
|
||||||
local connections: { [K]: () -> () } = {}
|
|
||||||
|
|
||||||
local function listener(state: { [K]: V })
|
|
||||||
for key, disconnect in next, connections do
|
|
||||||
if state[key] == nil then
|
|
||||||
connections[key] = nil
|
|
||||||
disconnect()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for key, value in next, state do
|
|
||||||
if not connections[key] then
|
|
||||||
connections[key] = factory(value, key) or noop
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local unsubscribe = subscribe(callback, listener)
|
|
||||||
|
|
||||||
store.peek(function(): ()
|
|
||||||
listener(callback())
|
|
||||||
end)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
unsubscribe()
|
|
||||||
for _, disconnect in next, connections do
|
|
||||||
disconnect()
|
|
||||||
end
|
|
||||||
table.clear(connections)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return observe
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type Atom<T> = types.Atom<T>
|
|
||||||
type Set<T> = types.Set<T>
|
|
||||||
type WeakMap<K, V> = types.WeakMap<K, V>
|
|
||||||
|
|
||||||
local __DEV__ = _G.__DEV__
|
|
||||||
|
|
||||||
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
|
|
||||||
|
|
||||||
local batched: Set<() -> ()> = {}
|
|
||||||
local batching = false
|
|
||||||
|
|
||||||
local capturing = {
|
|
||||||
stack = {} :: { Set<Atom<any>> },
|
|
||||||
index = 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Calls the given function and returns the result. If the function yields or
|
|
||||||
throws an error, the thread is closed and an error is thrown. Regardless of
|
|
||||||
the outcome, the `finally` function is called to clean up any resources.
|
|
||||||
|
|
||||||
@param callback The function to run.
|
|
||||||
@param finally Cleanup logic to run before error handling.
|
|
||||||
@param ... Arguments to pass to the callback.
|
|
||||||
@return The result of the callback.
|
|
||||||
]=]
|
|
||||||
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
|
|
||||||
if __DEV__ then
|
|
||||||
local thread = coroutine.create(callback)
|
|
||||||
local success, result = coroutine.resume(thread, ...)
|
|
||||||
|
|
||||||
if finally then
|
|
||||||
finally()
|
|
||||||
end
|
|
||||||
|
|
||||||
if coroutine.status(thread) == "suspended" then
|
|
||||||
local source, line, name = debug.info(callback, "sln")
|
|
||||||
|
|
||||||
coroutine.close(thread)
|
|
||||||
|
|
||||||
error(
|
|
||||||
"Yielding is not allowed in atom functions. Consider wrapping this code in a Promise or task.defer instead."
|
|
||||||
.. `\nFunction defined at: {source}:{line}`
|
|
||||||
.. if name == "" then "" else ` function {name}`
|
|
||||||
)
|
|
||||||
elseif not success then
|
|
||||||
local source, line, name = debug.info(callback, "sln")
|
|
||||||
|
|
||||||
error(
|
|
||||||
"An error occurred while running an atom function"
|
|
||||||
.. `\nFunction defined at: {source}:{line}`
|
|
||||||
.. (if name == "" then "" else ` function {name}`)
|
|
||||||
.. `\nError: {result}`
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
if not finally then
|
|
||||||
return callback(...)
|
|
||||||
end
|
|
||||||
|
|
||||||
local success, result = pcall(callback, ...)
|
|
||||||
|
|
||||||
finally()
|
|
||||||
assert(success, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Returns whether the given value is an atom.
|
|
||||||
|
|
||||||
@param value The value to check.
|
|
||||||
@return `true` if the value is an atom, otherwise `false`.
|
|
||||||
]=]
|
|
||||||
local function isAtom(value: any): boolean
|
|
||||||
return not not (value and listeners[value])
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Notifies all subscribers of the given atom that the state has changed.
|
|
||||||
|
|
||||||
@param atom The atom to notify.
|
|
||||||
]=]
|
|
||||||
local function notify(atom: Atom<any>)
|
|
||||||
if batching then
|
|
||||||
for listener in next, listeners[atom] do
|
|
||||||
batched[listener] = true
|
|
||||||
end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
for listener in next, table.clone(listeners[atom]) do
|
|
||||||
try(listener)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Returns the result of the function without subscribing to changes. If a
|
|
||||||
non-function value is provided, it is returned as is.
|
|
||||||
|
|
||||||
@param callback The atom or selector to get the state of.
|
|
||||||
@param args Arguments to pass to the function.
|
|
||||||
@return The current state.
|
|
||||||
]=]
|
|
||||||
local function peek<T, U...>(callback: ((U...) -> T) | T, ...: U...): T
|
|
||||||
if type(callback) ~= "function" then
|
|
||||||
return callback
|
|
||||||
end
|
|
||||||
|
|
||||||
if capturing.index == 0 then
|
|
||||||
return callback(...)
|
|
||||||
end
|
|
||||||
|
|
||||||
capturing.index += 1
|
|
||||||
capturing.stack[capturing.index] = {}
|
|
||||||
|
|
||||||
local result = try(callback, function()
|
|
||||||
capturing.stack[capturing.index] = nil
|
|
||||||
capturing.index -= 1
|
|
||||||
end, ...)
|
|
||||||
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Captures all atoms that are read during the function call and returns them
|
|
||||||
along with the result of the function. Useful for tracking dependencies.
|
|
||||||
|
|
||||||
@param callback The function to run.
|
|
||||||
@return A tuple containing the captured atoms and the result of the function.
|
|
||||||
]=]
|
|
||||||
local function capture<T, U...>(callback: (U...) -> T, ...: U...): (Set<Atom<any>>, T)
|
|
||||||
-- If the callback is an atom, return it immediately
|
|
||||||
if listeners[callback :: any] then
|
|
||||||
return { [callback :: any] = true }, peek(callback)
|
|
||||||
end
|
|
||||||
|
|
||||||
local dependencies: Set<Atom<any>> = {}
|
|
||||||
|
|
||||||
capturing.index += 1
|
|
||||||
capturing.stack[capturing.index] = dependencies
|
|
||||||
|
|
||||||
local result = try(callback, function()
|
|
||||||
capturing.stack[capturing.index] = nil
|
|
||||||
capturing.index -= 1
|
|
||||||
end, ...)
|
|
||||||
|
|
||||||
return dependencies, result
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Runs the given function and schedules listeners to be notified only once
|
|
||||||
after the function has completed. Useful for batching multiple changes.
|
|
||||||
|
|
||||||
@param callback The function to run.
|
|
||||||
]=]
|
|
||||||
local function batch(callback: () -> ())
|
|
||||||
if batching then
|
|
||||||
return callback()
|
|
||||||
end
|
|
||||||
|
|
||||||
batching = true
|
|
||||||
|
|
||||||
try(callback, function()
|
|
||||||
batching = false
|
|
||||||
end)
|
|
||||||
|
|
||||||
for listener in next, batched do
|
|
||||||
try(listener)
|
|
||||||
end
|
|
||||||
|
|
||||||
table.clear(batched)
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Subscribes the listener to the changes of the given atoms.
|
|
||||||
|
|
||||||
@param atoms The atoms to listen to.
|
|
||||||
@param listener The function to call when the atoms change.
|
|
||||||
@param ref Optionally bind the lifetime of the listener to a value.
|
|
||||||
]=]
|
|
||||||
local function connect(atoms: Set<Atom<any>>, listener: () -> (), ref: unknown?)
|
|
||||||
for atom in next, atoms do
|
|
||||||
listeners[atom][listener] = ref or true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Unsubscribes the listener from every atom it was connected to.
|
|
||||||
|
|
||||||
@param atoms The atoms to stop listening to.
|
|
||||||
@param listener The function to stop calling when the atoms change.
|
|
||||||
]=]
|
|
||||||
local function disconnect(atoms: Set<Atom<any>>, listener: () -> ())
|
|
||||||
for atom in next, atoms do
|
|
||||||
listeners[atom][listener] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
listeners = listeners,
|
|
||||||
capturing = capturing,
|
|
||||||
isAtom = isAtom,
|
|
||||||
notify = notify,
|
|
||||||
capture = capture,
|
|
||||||
batch = batch,
|
|
||||||
peek = peek,
|
|
||||||
connect = connect,
|
|
||||||
disconnect = disconnect,
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
local store = require(script.Parent.store)
|
|
||||||
local types = require(script.Parent.types)
|
|
||||||
type Selector<T> = types.Selector<T>
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Subscribes to changes in the given atom or selector. The callback is
|
|
||||||
called with the current state and the previous state immediately after a
|
|
||||||
change occurs.
|
|
||||||
|
|
||||||
@param callback The atom or selector to subscribe to.
|
|
||||||
@param listener The function to call when the state changes.
|
|
||||||
@return A function that unsubscribes the callback.
|
|
||||||
]=]
|
|
||||||
local function subscribe<T>(callback: Selector<T>, listener: (state: T, prev: T) -> ()): () -> ()
|
|
||||||
local dependencies, state = store.capture(callback)
|
|
||||||
local disconnected = false
|
|
||||||
|
|
||||||
local function handler()
|
|
||||||
local prevState = state
|
|
||||||
|
|
||||||
store.disconnect(dependencies, handler)
|
|
||||||
dependencies, state = store.capture(callback)
|
|
||||||
|
|
||||||
if not disconnected then
|
|
||||||
store.connect(dependencies, handler)
|
|
||||||
end
|
|
||||||
|
|
||||||
if state ~= prevState then
|
|
||||||
listener(state, prevState)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
store.connect(dependencies, handler)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
if not disconnected then
|
|
||||||
disconnected = true
|
|
||||||
store.disconnect(dependencies, handler)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return subscribe
|
|
||||||
264
packages/charm/src/system.luau
Normal file
264
packages/charm/src/system.luau
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
--[[
|
||||||
|
Reactive system adapted from alien-signals at
|
||||||
|
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 = {
|
||||||
|
deps: Link?,
|
||||||
|
depsTail: Link?,
|
||||||
|
subs: Link?,
|
||||||
|
subsTail: Link?,
|
||||||
|
flags: ReactiveFlags,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
version: number,
|
||||||
|
dep: ReactiveNode,
|
||||||
|
sub: ReactiveNode,
|
||||||
|
prevSub: Link?,
|
||||||
|
nextSub: Link?,
|
||||||
|
prevDep: Link?,
|
||||||
|
nextDep: Link?,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stack<T> = {
|
||||||
|
value: T,
|
||||||
|
prev: Stack<T>?,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReactiveFlags = number
|
||||||
|
|
||||||
|
local NONE = 0b0000000
|
||||||
|
local MUTABLE = 0b0000001
|
||||||
|
local WATCHING = 0b0000010
|
||||||
|
local RECURSED_CHECK = 0b0000100
|
||||||
|
local RECURSED = 0b0001000
|
||||||
|
local DIRTY = 0b0010000
|
||||||
|
local PENDING = 0b0100000
|
||||||
|
local PEEKING = 0b1000000
|
||||||
|
|
||||||
|
local ReactiveFlags = {
|
||||||
|
None = NONE,
|
||||||
|
Mutable = MUTABLE,
|
||||||
|
Watching = WATCHING,
|
||||||
|
RecursedCheck = RECURSED_CHECK,
|
||||||
|
Recursed = RECURSED,
|
||||||
|
Dirty = DIRTY,
|
||||||
|
Pending = PENDING,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function createReactiveSystem(props: {
|
||||||
|
update: (sub: ReactiveNode) -> boolean,
|
||||||
|
notify: (sub: ReactiveNode) -> (),
|
||||||
|
unwatched: (sub: ReactiveNode) -> (),
|
||||||
|
})
|
||||||
|
local update = props.update
|
||||||
|
local notify = props.notify
|
||||||
|
local unwatched = props.unwatched
|
||||||
|
|
||||||
|
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
|
||||||
|
local link = sub.depsTail
|
||||||
|
while link do
|
||||||
|
if link == checkLink then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
link = link.prevDep
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): ()
|
||||||
|
-- DEVIATION: Do not link signals to peeking subscribers
|
||||||
|
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local prevDep = sub.depsTail
|
||||||
|
if prevDep and prevDep.dep == dep then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local nextDep = if prevDep then prevDep.nextDep else sub.deps
|
||||||
|
if nextDep and nextDep.dep == dep then
|
||||||
|
nextDep.version = version
|
||||||
|
sub.depsTail = nextDep
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local prevSub = dep.subsTail
|
||||||
|
if prevSub and prevSub.version == version and prevSub.sub == sub then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local newLink: Link = {
|
||||||
|
version = version,
|
||||||
|
dep = dep,
|
||||||
|
sub = sub,
|
||||||
|
prevDep = prevDep,
|
||||||
|
nextDep = nextDep,
|
||||||
|
prevSub = prevSub,
|
||||||
|
}
|
||||||
|
sub.depsTail = newLink
|
||||||
|
dep.subsTail = newLink
|
||||||
|
if nextDep then
|
||||||
|
nextDep.prevDep = newLink
|
||||||
|
end
|
||||||
|
if prevDep then
|
||||||
|
prevDep.nextDep = newLink
|
||||||
|
else
|
||||||
|
sub.deps = newLink
|
||||||
|
end
|
||||||
|
if prevSub then
|
||||||
|
prevSub.nextSub = newLink
|
||||||
|
else
|
||||||
|
dep.subs = newLink
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unlink(link: Link, sub: ReactiveNode?): Link?
|
||||||
|
local sub = sub or link.sub
|
||||||
|
local dep = link.dep
|
||||||
|
local prevDep = link.prevDep
|
||||||
|
local nextDep = link.nextDep
|
||||||
|
local nextSub = link.nextSub
|
||||||
|
local prevSub = link.prevSub
|
||||||
|
if nextDep then
|
||||||
|
nextDep.prevDep = prevDep
|
||||||
|
else
|
||||||
|
sub.depsTail = prevDep
|
||||||
|
end
|
||||||
|
if prevDep then
|
||||||
|
prevDep.nextDep = nextDep
|
||||||
|
else
|
||||||
|
sub.deps = nextDep
|
||||||
|
end
|
||||||
|
if nextSub then
|
||||||
|
nextSub.prevSub = prevSub
|
||||||
|
else
|
||||||
|
dep.subsTail = prevSub
|
||||||
|
end
|
||||||
|
if prevSub then
|
||||||
|
prevSub.nextSub = nextSub
|
||||||
|
else
|
||||||
|
dep.subs = nextSub
|
||||||
|
if not nextSub then
|
||||||
|
unwatched(dep)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nextDep
|
||||||
|
end
|
||||||
|
|
||||||
|
local function propagate(link: Link): ()
|
||||||
|
repeat
|
||||||
|
local sub = link.sub
|
||||||
|
local flags = sub.flags
|
||||||
|
|
||||||
|
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
|
||||||
|
sub.flags = bit32.bor(flags, PENDING)
|
||||||
|
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
|
||||||
|
flags = NONE
|
||||||
|
elseif bit32.band(flags, RECURSED_CHECK) == 0 then
|
||||||
|
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
|
||||||
|
elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
|
||||||
|
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
|
||||||
|
flags = bit32.band(flags, MUTABLE)
|
||||||
|
else
|
||||||
|
-- selene: allow(if_same_then_else)
|
||||||
|
flags = NONE
|
||||||
|
end
|
||||||
|
|
||||||
|
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
|
||||||
|
notify(sub)
|
||||||
|
end
|
||||||
|
|
||||||
|
if bit32.btest(flags, MUTABLE) then
|
||||||
|
local subSubs = sub.subs
|
||||||
|
if subSubs then
|
||||||
|
propagate(subSubs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
link = link.nextSub :: Link
|
||||||
|
until not link
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shallowPropagate(link: Link): ()
|
||||||
|
repeat
|
||||||
|
local sub = link.sub
|
||||||
|
local flags = sub.flags
|
||||||
|
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
|
||||||
|
sub.flags = bit32.bor(flags, DIRTY)
|
||||||
|
if bit32.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
|
||||||
|
local subs = dep.subs :: Link
|
||||||
|
if subs.nextSub then
|
||||||
|
shallowPropagate(subs)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
|
||||||
|
if checkDirty(dep.deps :: Link, dep) then
|
||||||
|
if update(dep) then
|
||||||
|
local subs = dep.subs :: Link
|
||||||
|
if subs.nextSub then
|
||||||
|
shallowPropagate(subs)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
link = link.nextDep :: Link
|
||||||
|
until not link
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
link = link,
|
||||||
|
unlink = unlink,
|
||||||
|
propagate = propagate,
|
||||||
|
checkDirty = checkDirty,
|
||||||
|
shallowPropagate = shallowPropagate,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
ReactiveFlags = ReactiveFlags,
|
||||||
|
createReactiveSystem = createReactiveSystem,
|
||||||
|
}
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
export type Set<T> = { [T]: true }
|
|
||||||
|
|
||||||
export type WeakMap<K, V> = typeof(setmetatable({} :: { [K]: V }, { __mode = "k" }))
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
A primitive state container that can be read from and written to. When the
|
|
||||||
state changes, all subscribers are notified.
|
|
||||||
|
|
||||||
@param state The next state or a function that produces the next state.
|
|
||||||
@return The current state, if no arguments are provided.
|
|
||||||
]=]
|
|
||||||
export type Atom<T> = (state: (T | (T) -> T)?) -> T
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
A function that depends on one or more atoms and produces a state. Can be
|
|
||||||
used to derive state from atoms.
|
|
||||||
|
|
||||||
@template State The type of the state.
|
|
||||||
@return The current state.
|
|
||||||
]=]
|
|
||||||
export type Selector<T> = () -> T
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
Optional configuration for creating an atom.
|
|
||||||
]=]
|
|
||||||
export type AtomOptions<T> = {
|
|
||||||
--[=[
|
|
||||||
A function that determines whether the state has changed. By default,
|
|
||||||
a strict equality check (`===`) is used.
|
|
||||||
]=]
|
|
||||||
equals: (prev: T, next: T) -> boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
A payload that can be sent from the server to the client to synchronize
|
|
||||||
state between the two.
|
|
||||||
]=]
|
|
||||||
export type SyncPayload = {
|
|
||||||
type: "patch" | "init",
|
|
||||||
data: { [any]: any },
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "littensy/charm"
|
name = "littensy/charm"
|
||||||
version = "0.10.0"
|
version = "0.11.0-rc.2"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
|
exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
5
packages/react-charm/Charm.luau
Normal file
5
packages/react-charm/Charm.luau
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
||||||
|
return Charm
|
||||||
12
packages/react-charm/React.luau
Normal file
12
packages/react-charm/React.luau
Normal file
|
|
@ -0,0 +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)
|
||||||
1
packages/react-charm/ReactRoblox.luau
Normal file
1
packages/react-charm/ReactRoblox.luau
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "VideCharm",
|
"name": "react-charm",
|
||||||
"tree": {
|
"tree": {
|
||||||
"$path": "src"
|
"$path": "src"
|
||||||
}
|
}
|
||||||
30
packages/react-charm/package.json
Normal file
30
packages/react-charm/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "@rbxts/react-charm",
|
||||||
|
"version": "0.4.0-rc.2",
|
||||||
|
"description": "React bindings for Charm state management",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/init.luau",
|
||||||
|
"types": "src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"*.luau",
|
||||||
|
"!wally.toml",
|
||||||
|
"!wally.lock",
|
||||||
|
"!default.project.json",
|
||||||
|
"!**/__tests__"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
|
"publish:wally": "wally publish"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rbxts/charm": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@rbxts/react": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
||||||
|
|
||||||
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
_G.__ROACT_17_MOCK_SCHEDULER__ = true
|
||||||
|
|
||||||
local React = require(ReplicatedStorage.DevPackages.React)
|
local React = require("../../../React")
|
||||||
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
|
local ReactRoblox = require("../../../ReactRoblox")
|
||||||
|
|
||||||
type RenderedHook<Props, Result> = {
|
type RenderedHook<Props, Result> = {
|
||||||
result: Result,
|
result: Result,
|
||||||
|
|
@ -15,11 +13,11 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
|
||||||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||||
local hook = {}
|
local hook = {}
|
||||||
|
|
||||||
local function Component(props)
|
local function Component(props: Props)
|
||||||
hook.result = callback(props)
|
hook.result = callback(props)
|
||||||
end
|
end
|
||||||
|
|
||||||
function hook.rerender(newProps)
|
function hook.rerender(newProps: Props?)
|
||||||
props = newProps or props
|
props = newProps or props
|
||||||
ReactRoblox.act(function()
|
ReactRoblox.act(function()
|
||||||
root:render(React.createElement(Component, props))
|
root:render(React.createElement(Component, props))
|
||||||
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 {}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local ReactCharm = require("../")
|
||||||
|
local renderHook = require("./helpers/renderHook")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("tracks signal state", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local hook = renderHook(function()
|
||||||
|
return ReactCharm.useSignalBinding(getState)
|
||||||
|
end)
|
||||||
|
assert(hook.result:getValue() == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result:getValue() == 1, "should return 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("accepts a selector", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local hook = renderHook(function()
|
||||||
|
return ReactCharm.useSignalBinding(function()
|
||||||
|
return 2 * getState()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(hook.result:getValue() == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result:getValue() == 2, "should return 2")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("tracks dependencies", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local hook = renderHook(function()
|
||||||
|
return ReactCharm.useSignalBinding(getState, { getState })
|
||||||
|
end)
|
||||||
|
assert(hook.result:getValue() == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result:getValue() == 1, "should return 1")
|
||||||
|
|
||||||
|
getState, setState = Charm.signal(0)
|
||||||
|
hook.rerender()
|
||||||
|
assert(hook.result:getValue() == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result:getValue() == 1, "should return 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
46
packages/react-charm/src/__tests__/useSignalState.spec.luau
Normal file
46
packages/react-charm/src/__tests__/useSignalState.spec.luau
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local ReactCharm = require("../")
|
||||||
|
local renderHook = require("./helpers/renderHook")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("tracks signal state", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local hook = renderHook(function()
|
||||||
|
return ReactCharm.useSignalState(getState)
|
||||||
|
end)
|
||||||
|
assert(hook.result == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result == 1, "should return 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("accepts a selector", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local hook = renderHook(function()
|
||||||
|
return ReactCharm.useSignalState(function()
|
||||||
|
return 2 * getState()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(hook.result == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result == 2, "should return 2")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("tracks dependencies", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local hook = renderHook(function()
|
||||||
|
return ReactCharm.useSignalState(getState, { getState })
|
||||||
|
end)
|
||||||
|
assert(hook.result == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result == 1, "should return 1")
|
||||||
|
|
||||||
|
getState, setState = Charm.signal(0)
|
||||||
|
hook.rerender()
|
||||||
|
assert(hook.result == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(hook.result == 1, "should return 1")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
31
packages/react-charm/src/index.d.ts
vendored
Normal file
31
packages/react-charm/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Binding } from "@rbxts/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a React state that updates when the getter function updates.
|
||||||
|
* Re-renders the component when the value changes.
|
||||||
|
*
|
||||||
|
* If the `dependencies` array is provided, a new effect is created for the
|
||||||
|
* current `getter` when the dependencies change. Otherwise, the effect is
|
||||||
|
* created only once, when the component is mounted.
|
||||||
|
*
|
||||||
|
* @param getter The signal or selector to watch.
|
||||||
|
* @param dependencies An array of values used to memoize the getter.
|
||||||
|
* @return The current state.
|
||||||
|
*/
|
||||||
|
export function useSignalState<T>(getter: () => T, dependencies?: any[]): T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a React binding that updates when the getter function updates.
|
||||||
|
*
|
||||||
|
* If the `dependencies` array is provided, a new effect is created for the
|
||||||
|
* current `getter` when the dependencies change. Otherwise, the effect is
|
||||||
|
* created only once, when the component is mounted.
|
||||||
|
*
|
||||||
|
* @param getter The atom or selector to watch.
|
||||||
|
* @param dependencies An array of values used to memoize the getter.
|
||||||
|
* @return A binding that stores the current state.
|
||||||
|
*/
|
||||||
|
export function useSignalBinding<T>(getter: () => T, dependencies?: any[]): Binding<T>;
|
||||||
|
|
||||||
|
// Aliases
|
||||||
|
export { useSignalState as useAtom };
|
||||||
58
packages/react-charm/src/init.luau
Normal file
58
packages/react-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
local Charm = require("./Charm")
|
||||||
|
local React = require("./React")
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Returns a React state that updates when the getter function updates.
|
||||||
|
Re-renders the component when the value changes.
|
||||||
|
|
||||||
|
If the `dependencies` array is provided, a new effect is created for the
|
||||||
|
current `getter` when the dependencies change. Otherwise, the effect is
|
||||||
|
created only once, when the component is mounted.
|
||||||
|
|
||||||
|
@param getter The signal or selector to watch.
|
||||||
|
@param dependencies An array of values used to memoize the getter.
|
||||||
|
@return The current state.
|
||||||
|
]]
|
||||||
|
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
|
||||||
|
local value, setValue = React.useState(getter)
|
||||||
|
|
||||||
|
React.useLayoutEffect(function()
|
||||||
|
return Charm.effect(function()
|
||||||
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
|
Charm.untracked(setValue, getter())
|
||||||
|
end)
|
||||||
|
end, dependencies or {})
|
||||||
|
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Returns a React binding that updates when the getter function updates.
|
||||||
|
|
||||||
|
If the `dependencies` array is provided, a new effect is created for the
|
||||||
|
current `getter` when the dependencies change. Otherwise, the effect is
|
||||||
|
created only once, when the component is mounted.
|
||||||
|
|
||||||
|
@param getter The atom or selector to watch.
|
||||||
|
@param dependencies An array of values used to memoize the getter.
|
||||||
|
@return A binding that stores the current state.
|
||||||
|
]]
|
||||||
|
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
|
||||||
|
local initialValue = React.useMemo(getter, {})
|
||||||
|
local binding, setBinding = React.useBinding(initialValue)
|
||||||
|
|
||||||
|
React.useLayoutEffect(function()
|
||||||
|
return Charm.effect(function()
|
||||||
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
|
Charm.untracked(setBinding, getter())
|
||||||
|
end)
|
||||||
|
end, dependencies or {})
|
||||||
|
|
||||||
|
return binding
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
useSignalState = useSignalState,
|
||||||
|
useSignalBinding = useSignalBinding,
|
||||||
|
useAtom = useSignalState,
|
||||||
|
}
|
||||||
17
packages/react-charm/wally.toml
Normal file
17
packages/react-charm/wally.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "littensy/react-charm"
|
||||||
|
version = "0.4.0-rc.2"
|
||||||
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
|
realm = "shared"
|
||||||
|
exclude = [
|
||||||
|
"node_modules",
|
||||||
|
"src/__tests__",
|
||||||
|
"src/index.d.ts",
|
||||||
|
"package.json",
|
||||||
|
"React.luau",
|
||||||
|
"Charm.luau",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
Charm = "littensy/charm@0.11.0-rc.2"
|
||||||
|
React = "jsdotlua/react@^17"
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@rbxts/react-charm",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"description": "React bindings for Charm",
|
|
||||||
"repository": "littensy/charm",
|
|
||||||
"license": "MIT",
|
|
||||||
"main": "src/init.luau",
|
|
||||||
"types": "src/index.d.ts",
|
|
||||||
"scripts": {
|
|
||||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
|
||||||
"publish:wally": "wally publish"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@rbxts/charm": "workspace:*",
|
|
||||||
"@rbxts/react": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
packages/react/src/index.d.ts
vendored
15
packages/react/src/index.d.ts
vendored
|
|
@ -1,15 +0,0 @@
|
||||||
import { Selector } from "@rbxts/charm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A hook that subscribes to changes in the given atom or selector. The
|
|
||||||
* component is re-rendered whenever the state changes.
|
|
||||||
*
|
|
||||||
* If the `dependencies` array is provided, the subscription to the atom or
|
|
||||||
* selector is re-created whenever the dependencies change. Otherwise, the
|
|
||||||
* subscription is created once when the component is mounted.
|
|
||||||
*
|
|
||||||
* @param callback The atom or selector to subscribe to.
|
|
||||||
* @param dependencies An array of values that the subscription depends on.
|
|
||||||
* @returns The current state.
|
|
||||||
*/
|
|
||||||
export function useAtom<State>(callback: Selector<State>, dependencies?: unknown[]): State;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
local Charm = require(script.Parent.Charm)
|
|
||||||
local React = if script.Parent:FindFirstChild("react")
|
|
||||||
then require(script.Parent.react) :: never
|
|
||||||
else require(script.Parent.React)
|
|
||||||
|
|
||||||
--[=[
|
|
||||||
A hook that subscribes to changes in the given atom or selector. The
|
|
||||||
component is re-rendered whenever the state changes.
|
|
||||||
|
|
||||||
If the `dependencies` array is provided, the subscription to the atom or
|
|
||||||
selector is re-created whenever the dependencies change. Otherwise, the
|
|
||||||
subscription is created once when the component is mounted.
|
|
||||||
|
|
||||||
@param callback The atom or selector to subscribe to.
|
|
||||||
@param dependencies An array of values that the subscription depends on.
|
|
||||||
@return The current state.
|
|
||||||
]=]
|
|
||||||
local function useAtom<State>(callback: Charm.Selector<State>, dependencies: { any }?): State
|
|
||||||
local state, setState = React.useState(callback)
|
|
||||||
|
|
||||||
React.useEffect(function()
|
|
||||||
setState(callback())
|
|
||||||
return Charm.subscribe(callback, setState)
|
|
||||||
end, dependencies or {})
|
|
||||||
|
|
||||||
return state
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
useAtom = useAtom,
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "littensy/react-charm"
|
|
||||||
version = "0.3.0"
|
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
|
||||||
realm = "shared"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
charm = "littensy/charm@^0.10"
|
|
||||||
React = "jsdotlua/react@^17"
|
|
||||||
5
packages/vide-charm/Charm.luau
Normal file
5
packages/vide-charm/Charm.luau
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
||||||
|
return Charm
|
||||||
1
packages/vide-charm/Vide.luau
Normal file
1
packages/vide-charm/Vide.luau
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
return require(script.Parent.Parent.vide.src)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "ReactCharm",
|
"name": "vide-charm",
|
||||||
"tree": {
|
"tree": {
|
||||||
"$path": "src"
|
"$path": "src"
|
||||||
}
|
}
|
||||||
30
packages/vide-charm/package.json
Normal file
30
packages/vide-charm/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "@rbxts/vide-charm",
|
||||||
|
"version": "0.4.0-rc.2",
|
||||||
|
"description": "Vide bindings for Charm state management",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/init.luau",
|
||||||
|
"types": "src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"*.luau",
|
||||||
|
"!wally.toml",
|
||||||
|
"!wally.lock",
|
||||||
|
"!default.project.json",
|
||||||
|
"!**/__tests__"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/littensy/charm.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||||
|
"publish:wally": "wally publish"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rbxts/charm": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@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 {}
|
||||||
61
packages/vide-charm/src/__tests__/useSignalState.spec.luau
Normal file
61
packages/vide-charm/src/__tests__/useSignalState.spec.luau
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
local Charm = require("../../Charm")
|
||||||
|
local Vide = require("../../Vide")
|
||||||
|
local VideCharm = require("../")
|
||||||
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
|
local test = suite.test
|
||||||
|
|
||||||
|
test("tracks signal state", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local source
|
||||||
|
local cleanup = Vide.root(function()
|
||||||
|
source = VideCharm.useSignalState(getState)
|
||||||
|
end)
|
||||||
|
assert(source() == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(source() == 1, "should return 1")
|
||||||
|
cleanup()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("accepts a selector", function()
|
||||||
|
local getState, setState = Charm.signal(0)
|
||||||
|
local source
|
||||||
|
local cleanup = Vide.root(function()
|
||||||
|
source = VideCharm.useSignalState(function()
|
||||||
|
return 2 * getState()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(source() == 0, "should return 0")
|
||||||
|
setState(1)
|
||||||
|
assert(source() == 2, "should return 2")
|
||||||
|
cleanup()
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("updates source in untracked", function()
|
||||||
|
local getOuter, setOuter = Charm.signal(nil :: any)
|
||||||
|
local getInner, setInner = Charm.signal(0)
|
||||||
|
local spyCalls = 0
|
||||||
|
local cleanup = Vide.root(function()
|
||||||
|
return Vide.show(VideCharm.useSignalState(getOuter), function()
|
||||||
|
-- Charm effects created in Vide scopes triggered by useSignalState might
|
||||||
|
-- incorrectly get treated as an inner effect in useSignalState. This test
|
||||||
|
-- ensures that doesn't happen.
|
||||||
|
Vide.cleanup(Charm.effect(function()
|
||||||
|
getInner()
|
||||||
|
spyCalls += 1
|
||||||
|
end))
|
||||||
|
return nil
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
assert(spyCalls == 0, "should not call spy initially")
|
||||||
|
setOuter(true)
|
||||||
|
assert(spyCalls == 1, "should call spy when visible")
|
||||||
|
setInner(1)
|
||||||
|
assert(spyCalls == 2, "spy should run when counter updates")
|
||||||
|
setOuter({}) -- set to a truthy value to trigger an outer effect re-run
|
||||||
|
setInner(2)
|
||||||
|
assert(spyCalls == 3, "spy should still run when counter updates")
|
||||||
|
cleanup()
|
||||||
|
end)
|
||||||
|
|
||||||
|
return {}
|
||||||
11
packages/vide-charm/src/index.d.ts
vendored
Normal file
11
packages/vide-charm/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* Returns a reactive source that subscribes to the current value of the given
|
||||||
|
* signal or getter function.
|
||||||
|
*
|
||||||
|
* @param getter The getter function to subscribe to.
|
||||||
|
* @return The reactive source.
|
||||||
|
*/
|
||||||
|
export function useSignalState<T>(getter: () => T, dependencies?: any[]): () => T;
|
||||||
|
|
||||||
|
// Aliases
|
||||||
|
export { useSignalState as useAtom };
|
||||||
25
packages/vide-charm/src/init.luau
Normal file
25
packages/vide-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
local Charm = require("./Charm")
|
||||||
|
local Vide = require("./Vide")
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Returns a reactive source that subscribes to the current value of the given
|
||||||
|
signal or getter function.
|
||||||
|
|
||||||
|
@param getter The getter function to subscribe to.
|
||||||
|
@return The reactive source.
|
||||||
|
]]
|
||||||
|
local function useSignalState<State>(getter: () -> State): () -> State
|
||||||
|
local state = Vide.source(getter())
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
useSignalState = useSignalState,
|
||||||
|
useAtom = useSignalState,
|
||||||
|
}
|
||||||
17
packages/vide-charm/wally.toml
Normal file
17
packages/vide-charm/wally.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "littensy/vide-charm"
|
||||||
|
version = "0.4.0-rc.2"
|
||||||
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
|
realm = "shared"
|
||||||
|
exclude = [
|
||||||
|
"node_modules",
|
||||||
|
"src/__tests__",
|
||||||
|
"src/index.d.ts",
|
||||||
|
"package.json",
|
||||||
|
"Vide.luau",
|
||||||
|
"Charm.luau",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
Charm = "littensy/charm@0.11.0-rc.2"
|
||||||
|
Vide = "centau/vide@^0.3"
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@rbxts/vide-charm",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"description": "Vide bindings for Charm",
|
|
||||||
"repository": "littensy/charm",
|
|
||||||
"license": "MIT",
|
|
||||||
"main": "src/init.luau",
|
|
||||||
"types": "src/index.d.ts",
|
|
||||||
"scripts": {
|
|
||||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
|
||||||
"publish:wally": "wally publish"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@rbxts/charm": "workspace:*",
|
|
||||||
"@rbxts/vide": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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