mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
Compare commits
70 commits
main
...
charm-v0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e583574751 | ||
|
|
ed9566bfff | ||
|
|
29d8add265 | ||
|
|
898fbdba29 | ||
|
|
fdc598f6e6 | ||
|
|
d890f4fc2c | ||
|
|
96ab2317f2 | ||
|
|
dffeb35933 | ||
|
|
7bb6edb403 | ||
|
|
2aa7bd3e15 | ||
|
|
86ade3c36d | ||
|
|
3d4ce41d20 | ||
|
|
90399a816f | ||
|
|
682b3dfcff | ||
|
|
0f2f2d642c | ||
|
|
ea40833d3b | ||
|
|
37f79ecc3f | ||
|
|
7a88c784fa | ||
|
|
32788b19e1 | ||
|
|
1e0b10aef6 | ||
|
|
bf92413a97 | ||
|
|
7a63993293 | ||
|
|
027c5bf8f8 | ||
|
|
87e7260c16 | ||
|
|
1657e2cb74 | ||
|
|
f5709c3112 | ||
|
|
5a05f01223 | ||
|
|
0dbc0587a0 | ||
|
|
079abaacc1 | ||
|
|
4a07297a13 | ||
|
|
cb52ece4d3 | ||
|
|
a8051257e5 | ||
|
|
77c98ca94a | ||
|
|
708bc55de7 | ||
|
|
d2979ebd4c | ||
|
|
95476e93ec | ||
|
|
a9c60c701f | ||
|
|
581488cb4f | ||
|
|
61d9f424aa | ||
|
|
4edc952c05 | ||
|
|
4931515a95 | ||
|
|
25728e589f | ||
|
|
72f936591e | ||
|
|
210e948225 | ||
|
|
546df8cabe | ||
|
|
4e5f656158 | ||
|
|
a53683d59e | ||
|
|
571d2c0d91 | ||
|
|
dcb1de2844 | ||
|
|
3f1556a6e2 | ||
|
|
554010f1bd | ||
|
|
b61d0b3b86 | ||
|
|
f704acce71 | ||
|
|
ada1b80b87 | ||
|
|
d56672384a | ||
|
|
bcf02a615f | ||
|
|
6bfd2f3d21 | ||
|
|
939a812b43 | ||
|
|
1d5d3aed8c | ||
|
|
e276863b2d | ||
|
|
7c48bd2fa4 | ||
|
|
0b37f9cd54 | ||
|
|
d33c4ce891 | ||
|
|
8d385356cd | ||
|
|
3eba56e298 | ||
|
|
8df67d6fff | ||
|
|
209f9b583b | ||
|
|
a0fc88ede1 | ||
|
|
e34b0c9aca | ||
|
|
861ee5acd7 |
134 changed files with 7404 additions and 4225 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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: true
|
||||
|
||||
- name: Install Rokit
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Install Wally dependencies
|
||||
run: pnpm wally
|
||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- 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
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install Rokit
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
- uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Build models
|
||||
run: pnpm build
|
||||
run: sh bin/build.sh
|
||||
|
||||
- name: Upload release assets
|
||||
uses: AButler/upload-release-assets@v3.0
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,13 +1,13 @@
|
|||
/.vscode
|
||||
/node_modules
|
||||
/.pesde
|
||||
/build
|
||||
/modules/DevPackages
|
||||
/packages/**/node_modules
|
||||
/packages/**/wally.lock
|
||||
/packages/**/README.md
|
||||
/packages/**/LICENSE.md
|
||||
/packages/**/README.md
|
||||
/sourcemap.json
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
*.rbxm
|
||||
*.tgz
|
||||
.DS_Store
|
||||
roblox.d.luau
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "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,
|
||||
}
|
||||
79
benches/mapped.luau
Normal file
79
benches/mapped.luau
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
|
||||
local CharmSync = require(ReplicatedStorage.packages["charm-sync"].src)
|
||||
|
||||
local patch = CharmSync.patch
|
||||
|
||||
local USERS = 1000
|
||||
|
||||
|
|
@ -22,14 +24,18 @@ local function update(state)
|
|||
state.users = table.clone(state.users)
|
||||
|
||||
for user = 1, USERS do
|
||||
if math.random() > 0.5 then
|
||||
continue
|
||||
end
|
||||
local rng = math.random()
|
||||
|
||||
state.users[user] = {
|
||||
coins = math.random(1, 2),
|
||||
gems = math.random(1, 2),
|
||||
}
|
||||
if rng < 1 / 3 then
|
||||
continue
|
||||
elseif rng < 2 / 3 then
|
||||
state.users[user] = nil
|
||||
else
|
||||
state.users[user] = {
|
||||
coins = math.random(1, 2),
|
||||
gems = math.random(1, 2),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return state
|
||||
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
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
mkdir build
|
||||
rojo build packages/charm -o build/charm.rbxm
|
||||
rojo build packages/charm-sync -o build/charm-sync.rbxm
|
||||
rojo build packages/react -o build/react-charm.rbxm
|
||||
rojo build packages/vide -o build/vide-charm.rbxm
|
||||
rojo build build.project.json -o build/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
|
||||
|
||||
check() {
|
||||
echo "Checking $1"
|
||||
luau-lsp analyze \
|
||||
--defs=bin/roblox.d.luau \
|
||||
--flag:LuauFixIndexerSubtypingOrdering=true \
|
||||
--flag:LuauInstantiateInSubtyping=true \
|
||||
--sourcemap=sourcemap.json \
|
||||
--ignore="**/node_modules/**" \
|
||||
packages test benches
|
||||
|
||||
luau-lsp analyze \
|
||||
--defs=bin/roblox.d.luau \
|
||||
--defs=bin/testez.d.luau \
|
||||
--flag:LuauFixIndexerSubtypingOrdering=true \
|
||||
--flag:LuauInstantiateInSubtyping=true \
|
||||
--sourcemap=sourcemap.json \
|
||||
--ignore="**/_Index/**" \
|
||||
$1
|
||||
selene packages test benches
|
||||
|
||||
selene $1
|
||||
stylua --check $1
|
||||
}
|
||||
stylua --check packages test benches
|
||||
|
||||
eslint packages
|
||||
|
||||
for i in packages/*/src; do
|
||||
check $i
|
||||
done
|
||||
|
||||
check tests
|
||||
pnpm eslint packages
|
||||
|
||||
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",
|
||||
"globIgnorePaths": [
|
||||
"**/default.project.json",
|
||||
"node_modules/charm",
|
||||
"node_modules/charm-sync",
|
||||
"node_modules/react-charm",
|
||||
"node_modules/vide-charm"
|
||||
],
|
||||
"emitLegacyScripts": false,
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"DevPackages": {
|
||||
"$path": "modules/DevPackages",
|
||||
"Charm": {
|
||||
"$path": "packages/charm"
|
||||
"packages": {
|
||||
"$className": "Folder",
|
||||
"vide": {
|
||||
"$path": "node_modules/@rbxts/vide"
|
||||
},
|
||||
"CharmSync": {
|
||||
"$path": "packages/charm-sync"
|
||||
"charm": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/charm/src" }
|
||||
},
|
||||
"ReactCharm": {
|
||||
"$path": "packages/react"
|
||||
"charm-sync": {
|
||||
"$className": "Folder",
|
||||
"src": { "$path": "packages/charm-sync/src" },
|
||||
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
|
||||
},
|
||||
"VideCharm": {
|
||||
"$path": "packages/vide"
|
||||
"react-charm": {
|
||||
"$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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerScriptService": {
|
||||
"$className": "ServerScriptService",
|
||||
"Tests": {
|
||||
"$path": "tests"
|
||||
},
|
||||
"@rbxts-js": {
|
||||
"$path": "node_modules/@rbxts-js"
|
||||
},
|
||||
"test": {
|
||||
"$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,
|
||||
"scripts": {
|
||||
"test": "lune run test/spec.server.luau",
|
||||
"build": "sh bin/build.sh",
|
||||
"check": "sh bin/check.sh",
|
||||
"wally": "sh bin/install.sh",
|
||||
"publish:npm": "pnpm publish -r --access public",
|
||||
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
|
||||
},
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@rbxts/compiler-types": "3.0.0-types.0",
|
||||
"@rbxts/types": "^1.0.811",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-roblox-ts": "^0.0.36",
|
||||
"prettier": "^3.3.3",
|
||||
"@rbxts/react": "17.3.5-alpha.1",
|
||||
"@rbxts/react-roblox": "17.3.5-alpha.1",
|
||||
"@rbxts/types": "^1.0.883",
|
||||
"@rbxts/vide": "^0.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"eslint": "^9.36.0",
|
||||
"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",
|
||||
"typescript": "~5.5.4"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
{
|
||||
"name": "@rbxts/charm-sync",
|
||||
"version": "0.3.0",
|
||||
"description": "Sync server state with clients using Charm",
|
||||
"repository": "littensy/charm",
|
||||
"version": "0.4.0-rc.1",
|
||||
"description": "Simple server-to-client sync for Charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"*.luau",
|
||||
"!wally.toml",
|
||||
"!wally.lock",
|
||||
"!default.project.json",
|
||||
"!**/__tests__"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/littensy/charm.git"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
packages/charm-sync/src/__tests__/client.spec.luau
Normal file
114
packages/charm-sync/src/__tests__/client.spec.luau
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
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 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 {}
|
||||
96
packages/charm-sync/src/__tests__/e2e.spec.luau
Normal file
96
packages/charm-sync/src/__tests__/e2e.spec.luau
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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.atom({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.atom({ 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 atom 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 atom B should be updated to 1")
|
||||
|
||||
local prevClientB = clientB()
|
||||
serverA({ count = 2 })
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(clientB() == prevClientB, "Client atom B should not be updated")
|
||||
end)
|
||||
|
||||
test("should replicate historical server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = Charm.atom({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.atom({ 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 atom A should be {expected}`)
|
||||
end)
|
||||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedB, 1)
|
||||
assert(expected == clientB().count, `Client atom 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 atom B should be updated to 2")
|
||||
|
||||
local prevClientB = clientB()
|
||||
serverA({ count = 3 })
|
||||
serverA({ count = 4 })
|
||||
server.flush()
|
||||
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
||||
assert(clientB() == prevClientB, "Client atom B should not be updated")
|
||||
end)
|
||||
|
||||
return {}
|
||||
138
packages/charm-sync/src/__tests__/patch.spec.luau
Normal file
138
packages/charm-sync/src/__tests__/patch.spec.luau
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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 patches = patch.diff(prevState, nextState)
|
||||
|
||||
assert(patches ~= nil, "should return a patch")
|
||||
assert(patches.baz == "quux", "should return the changed value")
|
||||
assert(patches.foo == nil, "should omit unchanged values")
|
||||
end)
|
||||
|
||||
test("applies a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local patches = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
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 patches", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
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 patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
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 patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
|
||||
-- Not enough information is available to determine whether 'patches'
|
||||
-- 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("optionally validates state", function()
|
||||
local success = pcall(function()
|
||||
config.validatePatches = true
|
||||
patch.diff({}, { [1] = 1, a = true })
|
||||
end)
|
||||
|
||||
assert(not success, "should not allow mixed tables")
|
||||
|
||||
success = pcall(function()
|
||||
config.validatePatches = false
|
||||
patch.diff({}, { [1] = 1, a = true })
|
||||
end)
|
||||
|
||||
assert(success, "should skip validation if not requested")
|
||||
end)
|
||||
|
||||
return {}
|
||||
306
packages/charm-sync/src/__tests__/server.spec.luau
Normal file
306
packages/charm-sync/src/__tests__/server.spec.luau
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
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 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")
|
||||
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
a(2)
|
||||
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 validate state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({})
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function() end)
|
||||
|
||||
a({ [0.5] = true })
|
||||
assert(not pcall(server.flush), "Expected server.flush to fail")
|
||||
|
||||
a({ [0.5] = true, valid = true })
|
||||
server.flush()
|
||||
end)
|
||||
|
||||
test("should serialize state", function()
|
||||
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 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 {}
|
||||
27
packages/charm-sync/src/__tests__/spec.luau
Normal file
27
packages/charm-sync/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
|
||||
suite.describe("client", function()
|
||||
require("./client.spec")
|
||||
end)
|
||||
|
||||
suite.describe("e2e", function()
|
||||
require("./e2e.spec")
|
||||
end)
|
||||
|
||||
suite.describe("utils", function()
|
||||
require("./utils.spec")
|
||||
end)
|
||||
|
||||
suite.describe("patch", function()
|
||||
require("./patch.spec")
|
||||
end)
|
||||
|
||||
suite.describe("server", function()
|
||||
require("./server.spec")
|
||||
end)
|
||||
|
||||
suite.describe("validate", function()
|
||||
require("./validate.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
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 {}
|
||||
24
packages/charm-sync/src/__tests__/validate.spec.luau
Normal file
24
packages/charm-sync/src/__tests__/validate.spec.luau
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
local validate = require("../validate")
|
||||
|
||||
local test = suite.test
|
||||
|
||||
test("validates inputs", function()
|
||||
local cases: { [string]: { value: any, expected: any } } = {
|
||||
array = { value = { 1, 2, 3 }, expected = true },
|
||||
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
|
||||
}
|
||||
|
||||
for key, case in cases do
|
||||
local success, result: any = pcall(validate, case.value)
|
||||
if case.expected and not success then
|
||||
error(`Expected {key} to be valid, threw {result}`)
|
||||
elseif not case.expected and success then
|
||||
error(`Expected {key} to be invalid, but it was valid`)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -1,83 +1,80 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
local Charm = require("../Charm")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type ClientOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the server.
|
||||
]=]
|
||||
atoms: AtomMap,
|
||||
--[=[
|
||||
Whether to ignore patches sent before the client has been hydrated.
|
||||
Default is `true`.
|
||||
]=]
|
||||
ignoreUnhydrated: boolean?,
|
||||
}
|
||||
type Setter<T = any> = (update: ((T) -> T) | T) -> ()
|
||||
|
||||
type ClientSyncer = {
|
||||
--[=[
|
||||
Applies a patch or initializes the state of the atoms with the given
|
||||
payload from the server.
|
||||
local trackedSetters: { [string]: Setter } = {}
|
||||
|
||||
@param ...payloads The patches or hydration payloads to apply.
|
||||
]=]
|
||||
sync: (self: ClientSyncer, ...SyncPayload) -> (),
|
||||
}
|
||||
--- Registers a map of setters to the state with the corresponding keys on the
|
||||
--- server. When the server sends updates, these setters will be called with
|
||||
--- the new values.
|
||||
---
|
||||
--- Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
--- Note that `server.addSignalsToClient` still requires some way to get the
|
||||
--- state.
|
||||
---
|
||||
--- @param setters A map of setter functions to sync with the server.
|
||||
local function addSignals(setters: { [string]: Setter })
|
||||
for key, setter in setters do
|
||||
trackedSetters[key] = setter
|
||||
end
|
||||
end
|
||||
|
||||
--[=[
|
||||
Creates a `ClientSyncer` object that receives patches from the server and
|
||||
applies them to the local state.
|
||||
--- 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, ...)
|
||||
trackedSetters[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
@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
|
||||
--- Unsubscribes from all server state updates.
|
||||
local function removeAllSignals()
|
||||
table.clear(trackedSetters)
|
||||
end
|
||||
|
||||
local self = {} :: ClientSyncer
|
||||
local hydrated = false
|
||||
|
||||
local function hydrate(state: { [string | number]: any })
|
||||
hydrated = true
|
||||
|
||||
for key, value in next, state do
|
||||
local atom = atoms[key]
|
||||
atom(value)
|
||||
local function applyInitPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
local setter = trackedSetters[key]
|
||||
if setter then
|
||||
setter(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function apply(data: { [string | number]: any })
|
||||
local target = {}
|
||||
|
||||
for key, atom in next, atoms do
|
||||
target[key] = atom()
|
||||
end
|
||||
|
||||
target = patch.apply(target, data)
|
||||
|
||||
for key, atom in next, atoms do
|
||||
atom(target[key])
|
||||
end
|
||||
end
|
||||
|
||||
function self:sync(...)
|
||||
for index = 1, select("#", ...) do
|
||||
local payload: SyncPayload = select(index, ...)
|
||||
|
||||
Charm.batch(function()
|
||||
if payload.type == "init" then
|
||||
hydrate(payload.data)
|
||||
elseif not ignoreUnhydrated or hydrated then
|
||||
apply(payload.data)
|
||||
end
|
||||
local function applyPatchPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
local setter = trackedSetters[key]
|
||||
if setter then
|
||||
setter(function(current)
|
||||
return patch.apply(current, value)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
20
packages/charm-sync/src/config.luau
Normal file
20
packages/charm-sync/src/config.luau
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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
|
||||
446
packages/charm-sync/src/index.d.ts
vendored
446
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1,214 +1,280 @@
|
|||
import { Atom, Selector } from "@rbxts/charm";
|
||||
|
||||
export = CharmSync;
|
||||
export as namespace CharmSync;
|
||||
|
||||
type Cleanup = () => void;
|
||||
import { Atom, Setter } from "@rbxts/charm";
|
||||
|
||||
type Key = string | number | symbol;
|
||||
|
||||
declare namespace CharmSync {
|
||||
type AtomMap = Record<string, Atom<any>>;
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
export interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type SelectorMap = Record<string, Selector<any>>;
|
||||
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
type GetterMap = Record<string, () => any>;
|
||||
type SetterMap = Record<string, Setter<any>>;
|
||||
|
||||
/**
|
||||
* 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 GetterMap = GetterMap, FixArrays extends boolean = true> =
|
||||
| { type: "init"; data: StateOfMap<Getters> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
||||
|
||||
/**
|
||||
* Global configuration options that affect the behavior of Charm Sync.
|
||||
*/
|
||||
export const config: {
|
||||
/**
|
||||
* @deprecated Use `SelectorMap` instead.
|
||||
*/
|
||||
type MoleculeMap = SelectorMap;
|
||||
|
||||
/**
|
||||
* Infers the type of the return values produced by a map of functions.
|
||||
*/
|
||||
type StateOfMap<T> = {
|
||||
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
/**
|
||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
||||
* applies them to the local state.
|
||||
*
|
||||
* @client
|
||||
* @param options The atoms to synchronize with the server.
|
||||
* @returns A `ClientSyncer` object.
|
||||
*/
|
||||
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
|
||||
|
||||
/**
|
||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
||||
* hydrates the client's state.
|
||||
* The interval at which to send patches to the client, in seconds.
|
||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
* negative value to disable automatic syncing.
|
||||
*
|
||||
* @server
|
||||
* @param options The atoms to synchronize with the client.
|
||||
* @returns A `ServerSyncer` object.
|
||||
*/
|
||||
function server<Selectors extends SelectorMap, Serialize extends boolean = true>(
|
||||
options: ServerOptions<Selectors, Serialize>,
|
||||
): ServerSyncer<Selectors, Serialize>;
|
||||
|
||||
interval: number;
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple
|
||||
* times per frame, where each individual change is important. Defaults
|
||||
* to `false`.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
* @server
|
||||
*/
|
||||
function isNone(value: unknown): value is None;
|
||||
|
||||
interface NestedAtomMap {
|
||||
readonly [key: string]: NestedAtomMap | Atom<any>;
|
||||
}
|
||||
|
||||
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
|
||||
? Name
|
||||
: Name extends string
|
||||
? Path extends string
|
||||
? `${Path}/${Name}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
|
||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
||||
|
||||
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
|
||||
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
|
||||
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
|
||||
: Atoms[Name] extends NestedAtomMap
|
||||
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
preserveHistory: boolean;
|
||||
/**
|
||||
* Flattens a nested atom map into a single object with slash-separated
|
||||
* keys. Useful for recursively collecting atoms returned by modules.
|
||||
* When `true`, Charm will 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.
|
||||
*
|
||||
* @param atoms The nested atom map to flatten.
|
||||
* @return A flattened atom map.
|
||||
* @server
|
||||
*/
|
||||
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
fixArrays: boolean;
|
||||
/**
|
||||
* A type that should not be made partial in patches.
|
||||
*/
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
* 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`.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `sync.isNone` function.
|
||||
* @server
|
||||
*/
|
||||
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? Serialize extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
||||
: State;
|
||||
validatePatches: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @client
|
||||
*/
|
||||
export namespace client {
|
||||
/**
|
||||
* Registers a map of setters to the state with the corresponding keys on the
|
||||
* server. When the server sends updates, these setters will be called with
|
||||
* the new values.
|
||||
*
|
||||
* Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
* Note that `server.addSignalsToClient` still requires some way to get the
|
||||
* state.
|
||||
*
|
||||
* @param setters A map of setter functions to sync with the server.
|
||||
*/
|
||||
export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
* Unregisters from server state updates for the given keys. The signals
|
||||
* will retain their current values, but will no longer receive updates
|
||||
* from the server.
|
||||
*/
|
||||
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> =
|
||||
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
|
||||
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
||||
export function removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void;
|
||||
|
||||
interface ClientOptions<Atoms extends AtomMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the server.
|
||||
*/
|
||||
atoms: Atoms;
|
||||
/**
|
||||
* Whether to ignore patches sent before the client has been hydrated.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
ignoreUnhydrated?: boolean;
|
||||
}
|
||||
/**
|
||||
* Unsubscribes from all server state updates.
|
||||
*/
|
||||
export function removeAllSignals(): void;
|
||||
|
||||
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> {
|
||||
/**
|
||||
* The atoms to synchronize with the client.
|
||||
*/
|
||||
atoms: Selectors;
|
||||
/**
|
||||
* The interval at which to send patches to the client, in seconds.
|
||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
* negative value to disable automatic syncing.
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple times
|
||||
* per frame, where each individual change is important. Defaults to `false`.
|
||||
*
|
||||
* If `true`, the broadcaster will send a list of payloads to the client
|
||||
* instead of a single payload. The client will apply each payload in order
|
||||
* to reconstruct the state's changes over time.
|
||||
*/
|
||||
preserveHistory?: boolean;
|
||||
/**
|
||||
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
* to address remote event argument limitations. Defaults to `true`.
|
||||
*
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*/
|
||||
autoSerialize?: Serialize;
|
||||
}
|
||||
|
||||
interface ClientSyncer<Atoms extends AtomMap> {
|
||||
/**
|
||||
* Applies a patch or initializes the state of the atoms with the given
|
||||
* payload from the server.
|
||||
*
|
||||
* @param payloads The patches or hydration payloads to apply.
|
||||
*/
|
||||
sync(...payloads: SyncPayload<Atoms, boolean>[]): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
|
||||
/**
|
||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
* the client whenever the state changes. When a change occurs, the `callback`
|
||||
* is called with the player and the payloads to send.
|
||||
*
|
||||
* Note that a `payload` object should not be mutated. If you need to modify
|
||||
* a payload, apply the changes to a copy of the object.
|
||||
*
|
||||
* @param callback The function to call when the state changes.
|
||||
* @returns A cleanup function that unsubscribes all listeners.
|
||||
*/
|
||||
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors, Serialize>[]) => void): Cleanup;
|
||||
/**
|
||||
* Hydrates the client's state with the server's state. This should be
|
||||
* called when a player joins the game and requires the server's state.
|
||||
*
|
||||
* @param player The player to hydrate.
|
||||
*/
|
||||
hydrate(player: Player): void;
|
||||
}
|
||||
/**
|
||||
* Merges the server's state patches into the client's state, either
|
||||
* initializing it with the full state or merging a partial patch.
|
||||
*
|
||||
* @param payloads The state updates received from the server.
|
||||
*/
|
||||
export function patch<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
|
||||
payloads: SyncPayload<Getters, FixArrays>[],
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @server
|
||||
*/
|
||||
export namespace server {
|
||||
/**
|
||||
* Subscribes a client to the given signals. When an update occurs, the client
|
||||
* will receive a partial state patch to merge into their local state. May be
|
||||
* called multiple times to subscribe to additional keys.
|
||||
*
|
||||
* Atoms, computed signals, and signal getter functions are allowed. Note
|
||||
* that `client.addSignals` still requires some way to set the state.
|
||||
*
|
||||
* @param client The client receiving state updates.
|
||||
* @param getters A map of getter functions to sync with the client.
|
||||
*/
|
||||
export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): 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<Getters extends GetterMap = GetterMap>(
|
||||
client: Player,
|
||||
...keys: (keyof Getters)[]
|
||||
): 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 GetterMap = GetterMap, 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.
|
||||
*/
|
||||
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
local client = require(script.client)
|
||||
local flatten = require(script.flatten)
|
||||
local patch = require(script.patch)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
local client = require("@self/client")
|
||||
local config = require("@self/config")
|
||||
local patch = require("@self/patch")
|
||||
local server = require("@self/server")
|
||||
local types = require("@self/types")
|
||||
local utils = require("@self/utils")
|
||||
|
||||
export type None = types.None
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
--[=[
|
||||
Synchronizes state between the client and server. The server sends patches
|
||||
to the client, which applies them to its local state.
|
||||
]=]
|
||||
return {
|
||||
config = config,
|
||||
client = client,
|
||||
server = server,
|
||||
flatten = flatten,
|
||||
patch = patch,
|
||||
isNone = patch.isNone,
|
||||
flatten = utils.flatten,
|
||||
signalToAtom = utils.signalToAtom,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
local RunService = game:GetService("RunService")
|
||||
|
||||
--[=[
|
||||
Schedules a function to be called at a fixed interval of `seconds`.
|
||||
|
||||
@param callback The function to call.
|
||||
@param seconds The interval in seconds.
|
||||
@return A function that cancels the interval.
|
||||
]=]
|
||||
local function interval(callback: () -> (), seconds: number): () -> ()
|
||||
if seconds < 0 then
|
||||
return function() end
|
||||
end
|
||||
|
||||
local clock = 0
|
||||
|
||||
local connection = RunService.Heartbeat:Connect(function(delta)
|
||||
clock += delta
|
||||
if clock >= seconds then
|
||||
clock = 0
|
||||
callback()
|
||||
end
|
||||
end)
|
||||
|
||||
return function()
|
||||
connection:Disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
return interval
|
||||
|
|
@ -1,27 +1,40 @@
|
|||
local validate = require(script.Parent.validate)
|
||||
local Charm = require("../Charm")
|
||||
local config = require("./config")
|
||||
local types = require("./types")
|
||||
local validate = require("./validate")
|
||||
|
||||
local __DEV__ = _G.__DEV__
|
||||
local flags = Charm.flags
|
||||
|
||||
--[=[
|
||||
A special symbol that denotes the absence of a value. Used to represent
|
||||
deleted values in patches.
|
||||
]=]
|
||||
local None = { __none = "__none" }
|
||||
export type None = types.None
|
||||
|
||||
--- A special symbol that denotes the absence of a value. Used to represent
|
||||
--- deleted values in patches.
|
||||
local None: None = { __none = "__none" }
|
||||
|
||||
--- Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
--- removed from the state when the patch is applied.
|
||||
---
|
||||
--- @param value The value to check.
|
||||
--- @return `true` if the value is `None`, otherwise `false`.
|
||||
local function isNone(value: any): boolean
|
||||
return type(value) == "table" and value.__none == "__none"
|
||||
end
|
||||
|
||||
--[=[
|
||||
JSON serialization can drop the last values of a sparse array. For example,
|
||||
`{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
|
||||
serialization of sparse arrays can be resolved by converting them to
|
||||
dictionaries with string keys.
|
||||
--- 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
|
||||
|
||||
The `apply` function will convert these dictionaries back into arrays as
|
||||
long as the target array is not sparse. This function ensures that the diff
|
||||
function produces a patch that can be correctly applied.
|
||||
]=]
|
||||
--- JSON serialization can drop the last values of a sparse array. For example,
|
||||
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
|
||||
--- serialization of sparse arrays can be resolved by converting them to
|
||||
--- dictionaries with string keys.
|
||||
---
|
||||
--- The `apply` function will convert these dictionaries back into arrays as
|
||||
--- long as the target array is not sparse. This function ensures that the diff
|
||||
--- function produces a patch that can be correctly applied.
|
||||
local function stringifySparseArray(object: { any }): { [string | number]: any }
|
||||
local maxn = table.maxn(object)
|
||||
|
||||
|
|
@ -32,71 +45,64 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
|
|||
|
||||
local dictionary = {}
|
||||
|
||||
for index, value in next, object do
|
||||
for index, value in object do
|
||||
dictionary[tostring(index)] = value
|
||||
end
|
||||
|
||||
return dictionary
|
||||
end
|
||||
|
||||
--[=[
|
||||
Compares two states and returns a patch that can be applied to the previous
|
||||
state to produce the next state.
|
||||
--- 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.
|
||||
--- @return A patch that can be applied to the current state.
|
||||
local function diff(currentState: any, nextState: any): any
|
||||
if currentState == nextState then
|
||||
return nil
|
||||
elseif nextState == nil then
|
||||
return None
|
||||
elseif type(currentState) ~= "table" or type(nextState) ~= "table" then
|
||||
return nextState
|
||||
end
|
||||
|
||||
@param prevState The previous state.
|
||||
@param nextState The next state.
|
||||
@param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
|
||||
@returns A patch that can be applied to the previous state.
|
||||
]=]
|
||||
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
|
||||
serialize = serialize ~= false
|
||||
local patches: { [any]: any } = table.clone(nextState)
|
||||
|
||||
local patches = table.clone(nextState)
|
||||
for key, previous in currentState do
|
||||
patches[key] = diff(previous, nextState[key])
|
||||
end
|
||||
|
||||
for key, previous in next, prevState do
|
||||
local current = nextState[key]
|
||||
|
||||
if previous == current then
|
||||
patches[key] = nil
|
||||
elseif current == nil then
|
||||
patches[key] = None
|
||||
elseif type(previous) == "table" and type(current) == "table" then
|
||||
patches[key] = diff(previous, current, serialize)
|
||||
if config.fixArrays then
|
||||
-- Convert sparse array patches that could be misserialized
|
||||
if currentState[1] ~= nil or nextState[1] ~= nil then
|
||||
patches = stringifySparseArray(patches)
|
||||
end
|
||||
end
|
||||
|
||||
-- Coerce sparse array patches into dictionaries
|
||||
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
|
||||
patches = stringifySparseArray(patches)
|
||||
end
|
||||
|
||||
if serialize and __DEV__ then
|
||||
for key, value in next, patches do
|
||||
validate(value, key)
|
||||
if flags.strict and config.validatePatches then
|
||||
validate(patches)
|
||||
end
|
||||
end
|
||||
|
||||
return patches
|
||||
end
|
||||
|
||||
--[=[
|
||||
Applies a patch to a state and returns the next state.
|
||||
|
||||
@param state The current state.
|
||||
@param patches The patches to apply.
|
||||
@returns The next state.
|
||||
]=]
|
||||
local function apply(state: any, patches: any): any
|
||||
if type(patches) == "table" and patches.__none == "__none" then
|
||||
--- Applies a patch to a state and returns the resulting value.
|
||||
---
|
||||
--- @param currentState The current state.
|
||||
--- @param statePatch The patches to apply.
|
||||
--- @return The new state with the patch applied.
|
||||
local function apply(currentState: any, statePatch: any): any
|
||||
if isNone(statePatch) then
|
||||
return nil
|
||||
elseif type(state) ~= "table" or type(patches) ~= "table" then
|
||||
return patches
|
||||
elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then
|
||||
return statePatch
|
||||
end
|
||||
|
||||
local nextState = table.clone(state)
|
||||
local stateIsArray = state[1] ~= nil
|
||||
local nextState = table.clone(currentState)
|
||||
local stateIsArray = currentState[1] ~= nil
|
||||
|
||||
for key, patch in next, patches do
|
||||
for key, value in statePatch do
|
||||
-- Diff-checking an array produces a sparse array, which will not be
|
||||
-- preserved when converted to JSON. To prevent this, we turn string
|
||||
-- keys back into numeric keys.
|
||||
|
|
@ -104,7 +110,7 @@ local function apply(state: any, patches: any): any
|
|||
key = tonumber(key) or key
|
||||
end
|
||||
|
||||
nextState[key] = apply(nextState[key], patch)
|
||||
nextState[key] = apply(nextState[key], value)
|
||||
end
|
||||
|
||||
return nextState
|
||||
|
|
@ -112,6 +118,7 @@ end
|
|||
|
||||
return {
|
||||
isNone = isNone,
|
||||
nilToNone = nilToNone,
|
||||
diff = diff,
|
||||
apply = apply,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,267 +1,350 @@
|
|||
local Players = game:GetService("Players")
|
||||
local Charm = require("../Charm")
|
||||
local config = require("./config")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
|
||||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local interval = require(script.Parent.interval)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type ServerOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the client.
|
||||
]=]
|
||||
atoms: AtomMap,
|
||||
--[=[
|
||||
The interval at which to send patches to the client, in seconds.
|
||||
Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
negative value to disable automatic syncing.
|
||||
]=]
|
||||
interval: number?,
|
||||
--[=[
|
||||
Whether the history of state changes since the client's last update
|
||||
should be preserved. This is useful for values that change multiple times
|
||||
per frame, where each individual change is important. Defaults to `false`.
|
||||
]=]
|
||||
preserveHistory: boolean?,
|
||||
--[=[
|
||||
When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
to address remote event argument limitations. Defaults to `true`.
|
||||
type SyncStatus = number
|
||||
|
||||
This option should be disabled if your network library uses a custom
|
||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
]=]
|
||||
autoSerialize: boolean?,
|
||||
type ClientKeys = { [string]: SyncStatus }
|
||||
|
||||
type GetterState<T = any> = {
|
||||
getter: () -> T,
|
||||
currentValue: T,
|
||||
lastSyncedValue: T,
|
||||
dispose: () -> (),
|
||||
}
|
||||
|
||||
type ServerSyncer = {
|
||||
--[=[
|
||||
Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
the client whenever the state changes. When a change occurs, the `callback`
|
||||
is called with the player and the payload to send.
|
||||
|
||||
Note that the `payload` object should not be mutated. If you need to
|
||||
modify the payload, apply the changes to a copy of the object.
|
||||
|
||||
@param callback The function to call when the state changes.
|
||||
@return A cleanup function that unsubscribes all listeners.
|
||||
]=]
|
||||
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
|
||||
--[=[
|
||||
Hydrates the client's state with the server's state. This should be
|
||||
called when a player joins the game and requires the server's state.
|
||||
|
||||
@param player The player to hydrate.
|
||||
]=]
|
||||
hydrate: (self: ServerSyncer, player: Player) -> (),
|
||||
--[=[
|
||||
@deprecated For internal use only.
|
||||
]=]
|
||||
_sendPatch: (self: ServerSyncer, player: Player) -> (),
|
||||
type StateUpdate = {
|
||||
key: string,
|
||||
newValue: any,
|
||||
oldValue: any,
|
||||
}
|
||||
|
||||
--[=[
|
||||
Creates a `ServerSyncer` object that sends patches to the client and
|
||||
hydrates the client's state.
|
||||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||
|
||||
@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 trackedGetters: { [string]: GetterState } = {}
|
||||
local globalUpdates: { StateUpdate } = {}
|
||||
local clients: { [any]: ClientKeys } = {}
|
||||
|
||||
local self = {} :: ServerSyncer
|
||||
local sync: (player: Player, payload: SyncPayload) -> ()
|
||||
local connection: RBXScriptConnection?
|
||||
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
||||
|
||||
local function createSnapshot()
|
||||
local snapshot = {}
|
||||
|
||||
for key, atom in next, atoms do
|
||||
snapshot[key] = atom()
|
||||
local function startTracking<T>(key: string, getter: () -> T)
|
||||
if trackedGetters[key] then
|
||||
-- Ignore duplicates, but warn against name collisions
|
||||
if trackedGetters[key].getter ~= getter then
|
||||
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||
end
|
||||
|
||||
return snapshot
|
||||
return
|
||||
end
|
||||
|
||||
-- Send the initial state to a player when they join the server.
|
||||
function self:hydrate(player)
|
||||
assert(sync, "connect() must be called before hydrate()")
|
||||
local value = getter()
|
||||
local trackedState = {
|
||||
getter = getter,
|
||||
currentValue = value,
|
||||
lastSyncedValue = value,
|
||||
}
|
||||
|
||||
sync(player, {
|
||||
type = "init",
|
||||
data = createSnapshot(),
|
||||
})
|
||||
trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
|
||||
-- When preserving history, we need to keep track of previous values
|
||||
-- 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)
|
||||
|
||||
trackedGetters[key] = trackedState
|
||||
end
|
||||
|
||||
local function stopTracking(key: string)
|
||||
local trackedState = trackedGetters[key]
|
||||
if trackedState then
|
||||
trackedState.dispose()
|
||||
trackedGetters[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function purgeUntracked()
|
||||
local active: { [string]: boolean } = {}
|
||||
|
||||
for _, clientKeys in clients do
|
||||
for key in clientKeys do
|
||||
active[key] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- If the history of state changes should be preserved, keep a list of
|
||||
-- previous snapshots and diff-check each one from the last.
|
||||
if preserveHistory then
|
||||
local snapshots = { createSnapshot() }
|
||||
local changed = false
|
||||
|
||||
function self:connect(callback)
|
||||
local subscriptions = {}
|
||||
|
||||
sync = callback
|
||||
|
||||
local function pushSnapshot(key: string | number, current: any, previous: any)
|
||||
local lastSnapshot = snapshots[#snapshots]
|
||||
local previousSnapshot = snapshots[#snapshots - 1]
|
||||
|
||||
-- Optimize snapshots by updating the most recent snapshot if the
|
||||
-- previous and current values are the same, since this allows us
|
||||
-- to group multiple changes into a single snapshot.
|
||||
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
|
||||
lastSnapshot[key] = current
|
||||
else
|
||||
local nextSnapshot = table.clone(lastSnapshot)
|
||||
nextSnapshot[key] = current
|
||||
table.insert(snapshots, nextSnapshot)
|
||||
end
|
||||
end
|
||||
|
||||
-- Populate the snapshot with the initial state of each atom.
|
||||
-- Subscribe to each atom and update the state when it changes.
|
||||
for key, atom in next, atoms do
|
||||
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
|
||||
pushSnapshot(key, current, previous)
|
||||
changed = true
|
||||
end)
|
||||
end
|
||||
|
||||
local disconnect = interval(function()
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
local lastSnapshot
|
||||
|
||||
for index, snapshot in next, snapshots do
|
||||
lastSnapshot = snapshot
|
||||
|
||||
if index == 1 then
|
||||
continue
|
||||
end
|
||||
|
||||
table.insert(payloads, {
|
||||
type = "patch",
|
||||
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
|
||||
})
|
||||
end
|
||||
|
||||
snapshots = { lastSnapshot }
|
||||
changed = false
|
||||
|
||||
for _, player in next, Players:GetPlayers() do
|
||||
callback(player, unpack(payloads))
|
||||
end
|
||||
end, syncRate)
|
||||
|
||||
return function()
|
||||
disconnect()
|
||||
|
||||
for _, unsubscribe in next, subscriptions do
|
||||
unsubscribe()
|
||||
end
|
||||
end
|
||||
for key in trackedGetters do
|
||||
if not active[key] then
|
||||
stopTracking(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function self:_sendPatch(player: Player)
|
||||
assert(sync, "connect() must be called before _sendPatch()")
|
||||
local function diffGlobalUpdateBuffer(): { any }
|
||||
local patchCount = #globalUpdates
|
||||
local patches = table.create(patchCount)
|
||||
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
|
||||
for index, snapshot in next, snapshots do
|
||||
if index == 1 then
|
||||
continue
|
||||
end
|
||||
|
||||
table.insert(payloads, {
|
||||
type = "patch",
|
||||
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
|
||||
})
|
||||
end
|
||||
|
||||
snapshots = { snapshots[#snapshots] }
|
||||
changed = false
|
||||
|
||||
sync(player, unpack(payloads))
|
||||
end
|
||||
|
||||
return self
|
||||
for index, update in globalUpdates do
|
||||
patches[index] = patch.diff(update.oldValue, update.newValue)
|
||||
end
|
||||
|
||||
local previousSnapshot = createSnapshot()
|
||||
local currentSnapshot = table.clone(previousSnapshot)
|
||||
local changed = false
|
||||
return patches
|
||||
end
|
||||
|
||||
function self:connect(callback)
|
||||
local subscriptions = {}
|
||||
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
|
||||
local clientKeys = clients[client]
|
||||
|
||||
sync = callback
|
||||
if not clientKeys then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Subscribe to each atom and update the state when one changes.
|
||||
for key, atom in next, atoms do
|
||||
subscriptions[key] = Charm.subscribe(atom, function(state)
|
||||
currentSnapshot[key] = state
|
||||
changed = true
|
||||
end)
|
||||
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
|
||||
|
||||
local disconnect = interval(function()
|
||||
if not changed then
|
||||
return
|
||||
end
|
||||
local key = update.key
|
||||
local status = clientKeys[key]
|
||||
|
||||
local payload: SyncPayload = {
|
||||
if status == LISTENING_FOR_CHANGES then
|
||||
table.insert(payloads, {
|
||||
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)
|
||||
changed = false
|
||||
-- If a pending signal was never updated, we still need to send its initial
|
||||
-- state. This can happen if a signal was registered after its last change.
|
||||
local initData: { [string]: any } = {}
|
||||
|
||||
for _, player in next, Players:GetPlayers() do
|
||||
callback(player, payload)
|
||||
end
|
||||
end, syncRate)
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedGetters[key]
|
||||
|
||||
return function()
|
||||
disconnect()
|
||||
if status == PENDING_INITIAL_STATE and trackedState then
|
||||
initData[key] = trackedState.currentValue
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
for _, unsubscribe in next, subscriptions do
|
||||
unsubscribe()
|
||||
if next(initData) ~= nil then
|
||||
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 trackedGetters do
|
||||
currentGlobalState[key] = trackedState.currentValue
|
||||
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||
trackedState.lastSyncedValue = trackedState.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 = trackedGetters[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 getters A map of getter functions to sync with the client.
|
||||
local function addSignalsToClient(client: any, getters: { [string]: () -> any })
|
||||
if not clients[client] then
|
||||
clients[client] = {}
|
||||
end
|
||||
|
||||
local clientKeys = clients[client]
|
||||
|
||||
for key, getter in getters do
|
||||
startTracking(key, if type(getter) == "function" then getter else getter.get)
|
||||
|
||||
if not clientKeys[key] then
|
||||
-- Mark the getter 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
|
||||
|
||||
function self:_sendPatch(player)
|
||||
assert(sync, "connect() must be called before _sendPatch()")
|
||||
purgeUntracked()
|
||||
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
|
||||
end
|
||||
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
|
||||
}
|
||||
timer += deltaTime
|
||||
|
||||
previousSnapshot = table.clone(currentSnapshot)
|
||||
changed = false
|
||||
|
||||
sync(player, payload)
|
||||
end
|
||||
|
||||
return self
|
||||
if timer >= config.interval then
|
||||
timer = 0
|
||||
debug.profilebegin("CharmSync")
|
||||
flush()
|
||||
debug.profileend()
|
||||
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(trackedGetters)
|
||||
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,17 @@
|
|||
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
|
||||
--- the states of the client's atoms with the server.
|
||||
export type SyncPayload =
|
||||
| {
|
||||
type: "init",
|
||||
data: { [string]: any },
|
||||
}
|
||||
| {
|
||||
type: "patch",
|
||||
data: { [string]: any },
|
||||
}
|
||||
|
||||
--[=[
|
||||
A payload that can be sent from the server to the client to synchronize
|
||||
state between the two.
|
||||
]=]
|
||||
export type SyncPayload = {
|
||||
type: "init",
|
||||
data: { [string | number]: any },
|
||||
} | {
|
||||
type: "patch",
|
||||
data: { [string | number]: any },
|
||||
}
|
||||
--- A special type that represents a removed value in a patch. This is used to
|
||||
--- differentiate between a value that was set to `nil` and an unchanged value.
|
||||
export type None = { __none: "__none" }
|
||||
|
||||
export type AtomMap = {
|
||||
[string | number]: Atom<any>,
|
||||
}
|
||||
|
||||
return nil
|
||||
return {}
|
||||
|
|
|
|||
54
packages/charm-sync/src/utils.luau
Normal file
54
packages/charm-sync/src/utils.luau
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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
|
||||
|
||||
return {
|
||||
flatten = flatten,
|
||||
signalToAtom = signalToAtom,
|
||||
}
|
||||
|
|
@ -1,67 +1,50 @@
|
|||
local SAFE_KEYS = { string = true, number = true }
|
||||
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
||||
|
||||
local function isTableUnsafe(object: { [any]: any })
|
||||
local keyType = nil
|
||||
local objectSize = 0
|
||||
local key = next(object)
|
||||
|
||||
-- All keys must have the same type
|
||||
for key in next, object do
|
||||
local currentType = type(key)
|
||||
|
||||
if not keyType and SAFE_KEYS[currentType] then
|
||||
keyType = currentType
|
||||
elseif keyType ~= currentType then
|
||||
return true
|
||||
end
|
||||
|
||||
objectSize += 1
|
||||
if key == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
-- If there are more keys than the length of the array, it's an array with
|
||||
-- non-sequential keys.
|
||||
if objectSize > #object and keyType == "number" then
|
||||
local keyType = type(key)
|
||||
local maxKey = 0
|
||||
|
||||
-- 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
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--[=[
|
||||
Validates a value to ensure it can be synced over a remote event.
|
||||
|
||||
@param value The value to validate.
|
||||
@param key The key of the value in the table.
|
||||
@error Throws an error if the value cannot be synced.
|
||||
]=]
|
||||
local function validate(value: any, key: any)
|
||||
local typeOfKey = type(key)
|
||||
local typeOfValue = type(value)
|
||||
|
||||
if not SAFE_KEYS[typeOfKey] then
|
||||
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
|
||||
elseif UNSAFE_VALUES[typeOfValue] then
|
||||
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
|
||||
elseif typeOfValue == "table" then
|
||||
if getmetatable(value) ~= nil then
|
||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
||||
elseif isTableUnsafe(value) then
|
||||
error(
|
||||
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
|
||||
.. "This can be for the following reasons:\n"
|
||||
.. "1. The object is an array with non-sequential keys\n"
|
||||
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if typeOfKey == "number" then
|
||||
if key == math.huge or key == -math.huge then
|
||||
error("Cannot sync infinity as key")
|
||||
elseif key ~= math.floor(key) then
|
||||
error("Cannot sync non-integer number as key")
|
||||
--- Throws an error 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 table value.
|
||||
--- @param key The key of the value.
|
||||
--- @error Throws an error if the value cannot be synced.
|
||||
local function validate(value: { [any]: any })
|
||||
if isTableUnsafe(value) then
|
||||
-- Silence warnings during testing
|
||||
if game then
|
||||
warn("Cannot sync table:", value)
|
||||
end
|
||||
error(
|
||||
`Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
|
||||
.. "This can be for the following reasons:\n"
|
||||
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
[package]
|
||||
name = "littensy/charm-sync"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0-rc.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"src/__tests__",
|
||||
"src/index.d.ts",
|
||||
"package.json",
|
||||
"Charm.luau",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@0.11.0-rc.1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "Charm",
|
||||
"name": "charm",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
{
|
||||
"name": "@rbxts/charm",
|
||||
"version": "0.10.0",
|
||||
"description": "An atomic state management library for Roblox",
|
||||
"repository": "littensy/charm",
|
||||
"version": "0.11.0-rc.1",
|
||||
"description": "Atomic state management based on alien-signals",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"*.luau",
|
||||
"!wally.toml",
|
||||
"!wally.lock",
|
||||
"!default.project.json",
|
||||
"!**/__tests__"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/littensy/charm.git"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"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 {}
|
||||
66
packages/charm/src/__tests__/gc.spec.luau
Normal file
66
packages/charm/src/__tests__/gc.spec.luau
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
local Charm = require("../")
|
||||
local suite = require("../../../../test/suite")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local test = suite.test
|
||||
|
||||
--- Creates a weak reference to a value.
|
||||
local function weakRef<T>(value: T): { value: T? }
|
||||
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||
end
|
||||
|
||||
--- Triggers garbage collection by creating a large number of tables.
|
||||
local function gc()
|
||||
for _ = 1, 2000 do
|
||||
local _ = table.create(100, 0)
|
||||
end
|
||||
end
|
||||
|
||||
--- In alien-signals, a computed value unlinks from dependencies when its last
|
||||
--- parent scope is disposed. However, calling a computed value outside of a
|
||||
--- scope still links it to dependencies, which act as strong references.
|
||||
--- Computed values called in this way won't GC until unlinked from a scope.
|
||||
---
|
||||
--- This function releases a computed value by linking it to a scope and then
|
||||
--- immediately closing it, causing alien-signals to unlink the computed value
|
||||
--- from its dependencies and allow it to be garbage collected.
|
||||
local function unlink(getter: () -> ())
|
||||
effectScope(function()
|
||||
getter()
|
||||
end)()
|
||||
end
|
||||
|
||||
test("should release computed cache", function()
|
||||
local srcRef = weakRef({})
|
||||
local src = atom(srcRef.value)
|
||||
local c = computed(function()
|
||||
return src()
|
||||
end)
|
||||
|
||||
c() -- cache src value
|
||||
src(nil) -- release value
|
||||
c = unlink(c) -- release computed
|
||||
|
||||
gc()
|
||||
assert(srcRef.value == nil, "should release computed cache")
|
||||
end)
|
||||
|
||||
test("should not release effect for atom", function()
|
||||
local effectCalls = 0
|
||||
local src = atom(0)
|
||||
|
||||
effect(function()
|
||||
effectCalls += 1
|
||||
src()
|
||||
end)
|
||||
assert(effectCalls == 1, "should call effect once")
|
||||
|
||||
gc()
|
||||
src(src() + 1)
|
||||
assert(effectCalls == 2, "should call effect again")
|
||||
end)
|
||||
|
||||
return {}
|
||||
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 {}
|
||||
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 {}
|
||||
43
packages/charm/src/__tests__/spec.luau
Normal file
43
packages/charm/src/__tests__/spec.luau
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
|
||||
suite.describe("computed", function()
|
||||
require("./computed.spec")
|
||||
end)
|
||||
suite.describe("effect", function()
|
||||
require("./effect.spec")
|
||||
end)
|
||||
suite.describe("effectScope", function()
|
||||
require("./effectScope.spec")
|
||||
end)
|
||||
suite.describe("gc", function()
|
||||
require("./gc.spec")
|
||||
end)
|
||||
suite.describe("listen", function()
|
||||
require("./listen.spec")
|
||||
end)
|
||||
suite.describe("mapped", function()
|
||||
require("./mapped.spec")
|
||||
end)
|
||||
suite.describe("observe", function()
|
||||
require("./observe.spec")
|
||||
end)
|
||||
suite.describe("peek", function()
|
||||
require("./peek.spec")
|
||||
end)
|
||||
suite.describe("signal", function()
|
||||
require("./signal.spec")
|
||||
end)
|
||||
suite.describe("subscribe", function()
|
||||
require("./subscribe.spec")
|
||||
end)
|
||||
suite.describe("topology", function()
|
||||
require("./topology.spec")
|
||||
end)
|
||||
suite.describe("trigger", function()
|
||||
require("./trigger.spec")
|
||||
end)
|
||||
suite.describe("untracked", function()
|
||||
require("./untracked.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
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
|
||||
470
packages/charm/src/index.d.ts
vendored
470
packages/charm/src/index.d.ts
vendored
|
|
@ -1,187 +1,289 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
|
||||
type Key = string | number | symbol;
|
||||
type Cleanup = () => void;
|
||||
|
||||
declare namespace Charm {
|
||||
/**
|
||||
* A primitive state container that can be read from or written to. When
|
||||
* the state changes, all subscribers are notified.
|
||||
*
|
||||
* @param state The next state or a function that produces the next state.
|
||||
* @returns The current state.
|
||||
*/
|
||||
interface Atom<State> extends Selector<State> {
|
||||
(nextState: State | ((prevState: State) => State)): State;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Selector<State> = () => State;
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @deprecated Use `Selector<T>` instead.
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Molecule<State> = Selector<State>;
|
||||
|
||||
/**
|
||||
* Infers the type of the state produced by the given function.
|
||||
*/
|
||||
type StateOf<T> = T extends Selector<infer State> ? State : never;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
/**
|
||||
* A function that determines whether the state has changed. By default,
|
||||
* a strict equality check (`===`) is used.
|
||||
*/
|
||||
equals?: (prev: State, next: State) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new atom with the given state.
|
||||
*
|
||||
* @param state The initial state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new atom.
|
||||
*/
|
||||
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
|
||||
|
||||
// Overload for no arguments
|
||||
function atom<State>(state?: State, options?: AtomOptions<State>): Atom<State | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a read-only atom that derives its state from one or more atoms.
|
||||
* Used to avoid unnecessary recomputations if multiple listeners depend on
|
||||
* the same atoms.
|
||||
*
|
||||
* @param callback The function that produces the state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new read-only atom.
|
||||
*/
|
||||
function computed<State>(callback: Selector<State>, options?: AtomOptions<State>): Selector<State>;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the given atom or selector. The callback is
|
||||
* called with the current state and the previous state immediately after a
|
||||
* change occurs.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param listener The function to call when the state changes.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function subscribe<State>(callback: Selector<State>, listener: (state: State, prev: State) => void): Cleanup;
|
||||
|
||||
/**
|
||||
* Runs the given callback immediately and whenever any atom it depends on
|
||||
* changes. Returns a cleanup function that unsubscribes the callback.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function effect(callback: () => Cleanup | void): Cleanup;
|
||||
|
||||
/**
|
||||
* Returns the result of the function without subscribing to changes. If a
|
||||
* non-function value is provided, it is returned as is.
|
||||
*
|
||||
* @param callback The atom or selector to get the state of.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The current state.
|
||||
*/
|
||||
function peek<State, Args extends unknown[]>(callback: State | ((...args: Args) => State), ...args: Args): State;
|
||||
|
||||
/**
|
||||
* Returns whether the given value is an atom.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is an atom, otherwise `false`.
|
||||
*/
|
||||
function isAtom(value: unknown): value is Atom<any>;
|
||||
|
||||
/**
|
||||
* Runs the given function and schedules listeners to be notified only once
|
||||
* after the function has completed. Useful for batching multiple changes.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
*/
|
||||
function batch(callback: () => void): void;
|
||||
|
||||
/**
|
||||
* Captures all atoms that are read during the function call and returns them
|
||||
* along with the result of the function. Useful for tracking dependencies.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A tuple containing the captured atoms and the result of the function.
|
||||
*/
|
||||
function capture<State>(callback: Selector<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
|
||||
|
||||
/**
|
||||
* Notifies all subscribers of the given atom that the state has changed.
|
||||
* Mainly for running effects, since subscriptions check for equality.
|
||||
*
|
||||
* @param atom The atom to notify.
|
||||
*/
|
||||
function notify<State>(atom: Atom<State>): void;
|
||||
|
||||
/**
|
||||
* Creates an instance of `factory` for each item in the atom's state, and
|
||||
* cleans up the instance when the item is removed. Returns a cleanup function
|
||||
* that unsubscribes all instances.
|
||||
*
|
||||
* @param callback The atom or selector to observe.
|
||||
* @param factory The function that tracks the lifecycle of each item.
|
||||
* @returns A function that unsubscribes all instances.
|
||||
*/
|
||||
function observe<Item>(
|
||||
callback: Selector<readonly Item[]>,
|
||||
factory: (item: Item, index: number) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function observe<Key, Item>(
|
||||
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
function observe<Key extends string | number | symbol, Item>(
|
||||
callback: Selector<{ readonly [P in Key]: Item }>,
|
||||
factory: (item: Item, key: Key) => Cleanup | void,
|
||||
): Cleanup;
|
||||
|
||||
/**
|
||||
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
||||
* function returns `undefined`, the entry is omitted from the resulting map.
|
||||
* When the atom changes, the `mapper` is called for each entry in the state
|
||||
* to compute the new state.
|
||||
*
|
||||
* @param callback The atom or selector to map.
|
||||
* @param mapper The function that maps each entry.
|
||||
* @returns A new atom with the mapped state.
|
||||
*/
|
||||
function mapped<V0, K1, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function mapped<V0, V1>(
|
||||
callback: Selector<readonly V0[]>,
|
||||
mapper: (value: V0, index: number) => V1,
|
||||
): Selector<readonly V1[]>;
|
||||
|
||||
function mapped<K0, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
|
||||
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
|
||||
callback: Selector<{ readonly [P in K0]: V0 }>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): Selector<ReadonlyMap<K1, V1>>;
|
||||
export enum ReactiveFlags {
|
||||
None = 0b0000000,
|
||||
Mutable = 0b0000001,
|
||||
Watching = 0b0000010,
|
||||
RecursedCheck = 0b0000100,
|
||||
Recursed = 0b0001000,
|
||||
Dirty = 0b0010000,
|
||||
Pending = 0b0100000,
|
||||
Peeking = 0b1000000,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
export function getActiveSub(): ReactiveNode | undefined;
|
||||
|
||||
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||
|
||||
export function startBatch(): void;
|
||||
|
||||
export function endBatch(): void;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,841 @@
|
|||
local atom = require(script.atom)
|
||||
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)
|
||||
--[[
|
||||
Reactive system adapted from alien-signals at
|
||||
https://github.com/stackblitz/alien-signals/blob/master/src/index.ts
|
||||
|
||||
export type Atom<State> = types.Atom<State>
|
||||
export type Selector<State> = types.Selector<State>
|
||||
export type Molecule<State> = types.Selector<State>
|
||||
export type SyncPayload = types.SyncPayload
|
||||
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.
|
||||
]]
|
||||
|
||||
local system = require("@self/system")
|
||||
|
||||
export type ReactiveFlags = system.ReactiveFlags
|
||||
export type ReactiveNode = system.ReactiveNode
|
||||
export type Link = system.Link
|
||||
|
||||
export type Atom<T = any> = (...((T) -> T) | T) -> T
|
||||
|
||||
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
|
||||
if not success then
|
||||
warn("An error occurred during effect cleanup:", result)
|
||||
end
|
||||
cleanups[key] = nil
|
||||
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, 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
|
||||
if flags.frozen then
|
||||
deepFreeze(result)
|
||||
end
|
||||
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 tuple containing the getter and setter functions for the signal.
|
||||
--- @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 and noYield(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
|
||||
disposeScope()
|
||||
return
|
||||
end
|
||||
innerScopes[key] = disposeScope
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function disposeScopes()
|
||||
if not disposed then
|
||||
disposed = true
|
||||
for key, dispose in innerScopes do
|
||||
innerScopes[key] = nil
|
||||
dispose()
|
||||
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 disposeScopes
|
||||
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
|
||||
|
||||
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 {
|
||||
atom = atom,
|
||||
ReactiveFlags = system.ReactiveFlags,
|
||||
flags = flags,
|
||||
|
||||
getActiveSub = getActiveSub,
|
||||
setActiveSub = setActiveSub,
|
||||
startBatch = startBatch,
|
||||
endBatch = endBatch,
|
||||
|
||||
signal = signal,
|
||||
computed = computed,
|
||||
effect = effect,
|
||||
mapped = mapped,
|
||||
observe = observe,
|
||||
effectScope = effectScope,
|
||||
trigger = trigger,
|
||||
atom = atom,
|
||||
onCleanup = onCleanup,
|
||||
untracked = untracked,
|
||||
peek = peek,
|
||||
batch = batch,
|
||||
subscribe = subscribe,
|
||||
batch = store.batch,
|
||||
capture = store.capture,
|
||||
isAtom = store.isAtom,
|
||||
notify = store.notify,
|
||||
peek = store.peek,
|
||||
listen = listen,
|
||||
recursive = recursive,
|
||||
observe = observe,
|
||||
mapped = mapped :: (<KI, VI, KO, VO>(
|
||||
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 }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
name = "littensy/charm"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0-rc.1"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"]
|
||||
|
||||
[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": {
|
||||
"$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.1",
|
||||
"description": "React bindings for Charm state management",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"*.luau",
|
||||
"!wally.toml",
|
||||
"!wally.lock",
|
||||
"!default.project.json",
|
||||
"!**/__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
|
||||
|
||||
local React = require(ReplicatedStorage.DevPackages.React)
|
||||
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
|
||||
local React = require("../../../React")
|
||||
local ReactRoblox = require("../../../ReactRoblox")
|
||||
|
||||
type RenderedHook<Props, Result> = {
|
||||
result: Result,
|
||||
|
|
@ -15,11 +13,11 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
|
|||
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
|
||||
local hook = {}
|
||||
|
||||
local function Component(props)
|
||||
local function Component(props: Props)
|
||||
hook.result = callback(props)
|
||||
end
|
||||
|
||||
function hook.rerender(newProps)
|
||||
function hook.rerender(newProps: Props?)
|
||||
props = newProps or props
|
||||
ReactRoblox.act(function()
|
||||
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 };
|
||||
54
packages/react-charm/src/init.luau
Normal file
54
packages/react-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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.1"
|
||||
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.1"
|
||||
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": {
|
||||
"$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.1",
|
||||
"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 };
|
||||
23
packages/vide-charm/src/init.luau
Normal file
23
packages/vide-charm/src/init.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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.1"
|
||||
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.1"
|
||||
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