Compare commits

...

75 commits

Author SHA1 Message Date
littensy
f082956ed9 docs: fix typo 2025-11-18 20:50:48 -08:00
littensy
b733369e70 docs: reword reactive docs 2025-11-18 20:50:28 -08:00
littensy
5c90f928f7 chore: bump versions 2025-11-18 20:40:38 -08:00
littensy
bd23d54374 feat: add reactive() 2025-11-18 20:39:37 -08:00
richard
7bad215560
docs: fix typo 2025-11-16 07:02:38 -08:00
littensy
e583574751 test: make some tests more reliable 2025-11-15 20:41:38 -08:00
littensy
ed9566bfff chore: prepare release candidate 2025-11-15 20:38:15 -08:00
littensy
29d8add265 docs: update readme 2025-11-12 16:43:49 -08:00
littensy
898fbdba29 feat: add trigger(), refactor source 2025-11-12 16:28:41 -08:00
littensy
fdc598f6e6 fix: allow dispose in observers 2025-11-05 12:35:25 -08:00
littensy
d890f4fc2c feat: add recursive() 2025-11-05 02:30:41 -08:00
littensy
96ab2317f2 feat: update alien-signals, add recursion flag 2025-11-05 00:16:18 -08:00
littensy
dffeb35933 refactor: simplify charm sync validation 2025-11-04 22:30:10 -08:00
littensy
7bb6edb403 refactor: rename batch, misc fixes & perf updates 2025-11-04 21:39:28 -08:00
littensy
2aa7bd3e15 docs: rephrase computed arg change 2025-11-03 02:22:51 -08:00
littensy
86ade3c36d docs: fix whitespace 2025-11-03 02:16:45 -08:00
littensy
3d4ce41d20 docs: add migration tips 2025-11-03 02:15:15 -08:00
littensy
90399a816f docs: capitalize headers 2025-11-03 00:51:34 -08:00
littensy
682b3dfcff docs: simplify examples 2025-11-03 00:47:35 -08:00
littensy
0f2f2d642c docs: update sync section 2025-11-03 00:41:24 -08:00
littensy
ea40833d3b feat: finalize sync docs 2025-11-03 00:12:06 -08:00
littensy
37f79ecc3f refactor: remove unnecessary features, update docs 2025-11-02 18:55:41 -08:00
littensy
7a88c784fa feat: update alien-signals 2025-10-31 23:06:43 -07:00
littensy
32788b19e1 refactor: update charm sync api 2025-10-31 21:56:02 -07:00
littensy
1e0b10aef6 feat: add signal api 2025-10-31 00:36:55 -07:00
littensy
bf92413a97 refactor: align with alien-signals 3.0 2025-10-18 21:59:34 -07:00
littensy
7a63993293 refactor: add docs, remove some modules 2025-10-03 20:45:01 -07:00
littensy
027c5bf8f8 feat: add flag for inner effect tracking 2025-10-02 19:37:03 -07:00
littensy
87e7260c16 fix: wrap hooks in untracked, update types 2025-10-02 19:30:40 -07:00
littensy
1657e2cb74 feat: add peek, make untracked escape scope 2025-10-02 19:29:04 -07:00
littensy
f5709c3112 feat: prepare for alpha release 2025-10-02 01:43:28 -07:00
littensy
5a05f01223 test: add all sync tests 2025-10-01 01:30:31 -07:00
littensy
0dbc0587a0 fix: send init payload even if atom did not update 2025-09-29 18:30:35 -07:00
littensy
079abaacc1 refactor: start charm-sync rewrite 2025-09-29 18:11:39 -07:00
littensy
4a07297a13 chore: update eslint, housekeeping 2025-09-25 15:17:08 -07:00
littensy
cb52ece4d3 refactor: rewrite untracked, update alien-signals 2025-09-23 16:50:39 -07:00
littensy
a8051257e5 refactor: reduce type casting, housekeeping 2025-07-20 20:52:17 -07:00
littensy
77c98ca94a feat: add deep freeze flag, misc refactors 2025-07-05 13:38:18 -07:00
littensy
708bc55de7 feat: add peek and roblox-ts types 2025-07-02 21:58:55 -07:00
littensy
d2979ebd4c ci: update test file path 2025-07-02 09:28:38 -07:00
littensy
95476e93ec refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
littensy
a9c60c701f test: use lune 2025-05-21 15:42:48 -07:00
littensy
581488cb4f ci: init submodules 2025-05-21 12:24:53 -07:00
littensy
61d9f424aa ci: add pnpm step 2025-05-21 12:22:00 -07:00
littensy
4edc952c05 chore: housekeeping 2025-05-21 12:16:29 -07:00
littensy
4931515a95 refactor: improve logic 2025-05-12 19:26:15 -07:00
littensy
25728e589f style: use --- doc comments 2025-05-12 02:20:41 -07:00
littensy
72f936591e refactor: improve logic 2025-05-12 02:15:49 -07:00
littensy
210e948225 refactor: iterate over previous data in mapped 2025-05-11 17:49:01 -07:00
littensy
546df8cabe test: separate parent from root 2025-05-11 14:45:24 -07:00
littensy
4e5f656158 test: improve test suite 2025-05-11 04:56:46 -07:00
littensy
a53683d59e refactor: use setCurrentSub instead of pauseTracking 2025-05-08 00:12:51 -07:00
littensy
571d2c0d91 refactor: sync 2.0.4 2025-05-08 00:04:00 -07:00
littensy
dcb1de2844 test: color brackets 2025-05-06 19:40:24 -07:00
littensy
3f1556a6e2 test: try different gray 2025-05-06 19:37:54 -07:00
littensy
554010f1bd test: prettify test library 2025-05-06 19:32:19 -07:00
littensy
b61d0b3b86 perf: simplify mapped logic 2025-05-06 17:07:30 -07:00
littensy
f704acce71 chore: delete redundant benchmarks 2025-05-06 16:27:50 -07:00
littensy
ada1b80b87 perf: optimize mapped, add benches 2025-05-06 16:27:06 -07:00
littensy
d56672384a refactor: add assertion error messages 2025-05-06 11:18:19 -07:00
littensy
bcf02a615f refactor: avoid ambiguous optional function type 2025-05-06 11:06:27 -07:00
littensy
6bfd2f3d21 perf: optimize subscribe and mapped 2025-05-06 11:04:39 -07:00
littensy
939a812b43 chore: rephrase doc comment 2025-05-06 02:30:33 -07:00
littensy
1d5d3aed8c test: simplify mapped test update 2025-05-06 02:28:05 -07:00
littensy
e276863b2d feat: add detached, add more tests 2025-05-06 02:26:36 -07:00
littensy
7c48bd2fa4 build: install dev deps with wally 2025-05-06 00:33:56 -07:00
littensy
0b37f9cd54 ci: simplify pesde install 2025-05-05 22:38:59 -07:00
littensy
d33c4ce891 fix: update pkgs dir references 2025-05-05 22:18:12 -07:00
littensy
8d385356cd refactor: embrace scope cleanup, restructure code 2025-05-05 22:05:41 -07:00
littensy
3eba56e298 refactor: exclude re-exports from wally 2025-05-05 01:32:51 -07:00
littensy
8df67d6fff refactor: improve imports 2025-05-05 01:30:35 -07:00
littensy
209f9b583b fix: check previous value before calling listener 2025-05-03 21:17:03 -07:00
littensy
a0fc88ede1 fix: effectScope should set scope 2025-05-03 21:16:53 -07:00
littensy
e34b0c9aca refactor: remove pnpm, fix tests 2025-05-03 20:30:10 -07:00
littensy
861ee5acd7 feat: use alien-signals 2025-05-03 18:24:39 -07:00
135 changed files with 7991 additions and 4251 deletions

View file

@ -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
View file

@ -1 +0,0 @@
* text=auto eol=lf

View file

@ -1,31 +1,24 @@
name: CI name: CI
on: on: [push, pull_request]
push:
branches:
- main
pull_request:
branches:
- main
jobs: jobs:
analyze: analyze:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
run_install: true run_install: true
- uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Wally dependencies
run: pnpm wally
- name: Check code quality - name: Check code quality
run: pnpm check run: sh bin/check.sh
- name: Run tests
run: lune run test/spec.server.luau
- name: Build models - name: Build models
run: pnpm build run: sh bin/build.sh

View file

@ -9,13 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: Build models - name: Build models
run: pnpm build run: sh bin/build.sh
- name: Upload release assets - name: Upload release assets
uses: AButler/upload-release-assets@v3.0 uses: AButler/upload-release-assets@v3.0

10
.gitignore vendored
View file

@ -1,13 +1,13 @@
/.vscode /.vscode
/node_modules /.pesde
/build /build
/modules/DevPackages
/packages/**/node_modules
/packages/**/wally.lock /packages/**/wally.lock
/packages/**/README.md
/packages/**/LICENSE.md /packages/**/LICENSE.md
/packages/**/README.md
/sourcemap.json /sourcemap.json
node_modules
*.tsbuildinfo *.tsbuildinfo
*.tgz
*.rbxm *.rbxm
*.tgz
.DS_Store .DS_Store
roblox.d.luau

3
.gitmodules vendored Normal file
View file

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

View file

@ -1,3 +1,6 @@
{ {
"languageMode": "strict" "languageMode": "strict",
"lint": {
"LocalShadow": false
}
} }

1
.npmrc Normal file
View file

@ -0,0 +1 @@
public-hoist-pattern[]=*@rbxts*

1371
README.md

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Before After
Before After

48
benches/bench.luau Normal file
View file

@ -0,0 +1,48 @@
local profiles: { [string]: number } = {}
local times: { [string]: number } = {}
local stack: { string } = {}
local function run(name: string, test: () -> ())
local start = os.clock()
local iter = 0
table.clear(profiles)
table.clear(times)
table.clear(stack)
while os.clock() - start < 2 do
iter += 1
test()
end
local elapsed = os.clock() - start
local results = {}
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
for name, elapsed in times do
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
end
print(table.concat(results, " | "))
end
local function profilebegin(name: string)
profiles[name] = os.clock()
table.insert(stack, name)
end
local function profileend()
local name = table.remove(stack)
if name then
times[name] = (times[name] or 0) + os.clock() - profiles[name]
profiles[name] = nil
end
end
return {
run = run,
profilebegin = profilebegin,
profileend = profileend,
}

83
benches/mapped.luau Normal file
View file

@ -0,0 +1,83 @@
local Charm = require("../packages/charm/src")
local bench = require("./bench")
local computed = Charm.computed
local mapped = Charm.mapped
local setActiveSub = Charm.setActiveSub
local signal = Charm.signal
type Entry = { number }
local count = 512
local initialState: { Entry } = table.create(count)
for i = 1, count do
initialState[i] = table.create(64, i)
end
local function mapper(value: Entry, key: number): (Entry, number)
return table.clone(value), key + 1
end
local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) -> (Entry, number)): () -> { Entry }
return computed(function()
local state = source()
local nextState = {}
for index, value in state do
local currentSub = setActiveSub(nil)
local success, valueOut, keyOut = pcall(mapper, value, index)
setActiveSub(currentSub)
if success then
nextState[keyOut] = valueOut
end
end
return state
end)
end
local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
return function(state)
local nextState = table.clone(state)
for index, value in state do
if math.random() < percent then
nextState[index] = table.clone(value)
end
end
return nextState
end
end
for _, percent in { 0.1, 0.5, 1 } do
bench.run(`mapped {percent}`, function()
local src, setSrc = signal(initialState)
local out = mapped(src, mapper)
bench.profilebegin("init")
out()
bench.profileend()
setSrc(updatePercentage(percent))
bench.profilebegin("update")
out()
bench.profileend()
end)
bench.run(`control {percent}`, function()
local src, setSrc = signal(initialState)
local out = simpleMapped(src, mapper)
bench.profilebegin("init")
out()
bench.profileend()
setSrc(updatePercentage(percent))
bench.profilebegin("update")
out()
bench.profileend()
end)
end

47
benches/propagate.luau Normal file
View file

@ -0,0 +1,47 @@
local Charm = require("../packages/charm/src")
local bench = require("./bench")
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local signal = Charm.signal
for _, w in { 10, 100 } do
for _, h in { 10, 100 } do
bench.run(`propagate {w}*{h}`, function()
local src, setSrc = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
setSrc(src() + 1)
end)
end
end
for _, w in { 10, 100 } do
for _, h in { 10, 100 } do
bench.run(`propagate (listen) {w}*{h}`, function()
local src, setSrc = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
listen(last, function() end)
end
setSrc(src() + 1)
end)
end
end

87
benches/reactive.luau Normal file
View file

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

View file

@ -1,5 +1,4 @@
#!/usr/bin/env bash
mkdir build mkdir build
rojo build packages/charm -o build/charm.rbxm rojo build build.project.json -o build/charm.rbxm
rojo build packages/charm-sync -o build/charm-sync.rbxm
rojo build packages/react -o build/react-charm.rbxm
rojo build packages/vide -o build/vide-charm.rbxm

View file

@ -1,29 +1,19 @@
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
rojo sourcemap -o sourcemap.json rojo sourcemap -o sourcemap.json
check() { luau-lsp analyze \
echo "Checking $1" --defs=bin/roblox.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \
--flag:LuauInstantiateInSubtyping=true \
--sourcemap=sourcemap.json \
--ignore="**/node_modules/**" \
packages test benches
luau-lsp analyze \ selene packages test benches
--defs=bin/roblox.d.luau \
--defs=bin/testez.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \
--flag:LuauInstantiateInSubtyping=true \
--sourcemap=sourcemap.json \
--ignore="**/_Index/**" \
$1
selene $1 stylua --check packages test benches
stylua --check $1
}
eslint packages pnpm eslint packages
for i in packages/*/src; do
check $i
done
check tests
rm bin/roblox.d.luau rm bin/roblox.d.luau

View file

@ -1,4 +0,0 @@
cd modules
wally install
cd ..
rojo sourcemap --output sourcemap.json

View file

@ -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
View 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" }
}
}

View file

@ -1,29 +1,50 @@
{ {
"name": "charm-test", "name": "charm-test",
"globIgnorePaths": [
"**/default.project.json",
"node_modules/charm",
"node_modules/charm-sync",
"node_modules/react-charm",
"node_modules/vide-charm"
],
"emitLegacyScripts": false,
"tree": { "tree": {
"$className": "DataModel", "$className": "DataModel",
"ReplicatedStorage": { "ReplicatedStorage": {
"$className": "ReplicatedStorage", "$className": "ReplicatedStorage",
"DevPackages": { "packages": {
"$path": "modules/DevPackages", "$className": "Folder",
"Charm": { "vide": {
"$path": "packages/charm" "$path": "node_modules/@rbxts/vide"
}, },
"CharmSync": { "charm": {
"$path": "packages/charm-sync" "$className": "Folder",
"src": { "$path": "packages/charm/src" }
}, },
"ReactCharm": { "charm-sync": {
"$path": "packages/react" "$className": "Folder",
"src": { "$path": "packages/charm-sync/src" },
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
}, },
"VideCharm": { "react-charm": {
"$path": "packages/vide" "$className": "Folder",
"src": { "$path": "packages/react-charm/src" },
"React": { "$path": "packages/react-charm/React.luau" },
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
"Charm": { "$path": "packages/react-charm/Charm.luau" }
},
"vide-charm": {
"$className": "Folder",
"src": { "$path": "packages/vide-charm/src" },
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
} }
} },
}, "@rbxts-js": {
"ServerScriptService": { "$path": "node_modules/@rbxts-js"
"$className": "ServerScriptService", },
"Tests": { "test": {
"$path": "tests" "$path": "test"
} }
} }
} }

20
eslint.config.ts Normal file
View 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,
);

View file

@ -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 = []

View file

@ -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"

View file

@ -1,24 +1,30 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"test": "lune run test/spec.server.luau",
"build": "sh bin/build.sh", "build": "sh bin/build.sh",
"check": "sh bin/check.sh", "check": "sh bin/check.sh",
"wally": "sh bin/install.sh",
"publish:npm": "pnpm publish -r --access public", "publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
}, },
"packageManager": "pnpm@10.22.0",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0",
"@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/compiler-types": "3.0.0-types.0",
"@rbxts/types": "^1.0.811", "@rbxts/react": "17.3.5-alpha.1",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@rbxts/react-roblox": "17.3.5-alpha.1",
"@typescript-eslint/parser": "^7.18.0", "@rbxts/types": "^1.0.883",
"eslint": "^8.57.1", "@rbxts/vide": "^0.5.7",
"eslint-config-prettier": "^9.1.0", "@typescript-eslint/eslint-plugin": "^8.44.1",
"eslint-plugin-prettier": "^5.2.1", "@typescript-eslint/parser": "^8.44.1",
"eslint-plugin-roblox-ts": "^0.0.36", "eslint": "^9.36.0",
"prettier": "^3.3.3", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-roblox-ts": "^1.2.1",
"jiti": "^2.6.0",
"prettier": "^3.6.2",
"roblox-ts": "^3.0.0", "roblox-ts": "^3.0.0",
"typescript": "~5.5.4" "typescript": "^5.9.2",
}, "typescript-eslint": "^8.44.1"
"packageManager": "pnpm@9.15.4" }
} }

View 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

View file

@ -1,5 +1,5 @@
{ {
"name": "CharmSync", "name": "charm-sync",
"tree": { "tree": {
"$path": "src" "$path": "src"
} }

View file

@ -1,16 +1,27 @@
{ {
"name": "@rbxts/charm-sync", "name": "@rbxts/charm-sync",
"version": "0.3.0", "version": "0.4.0-rc.2",
"description": "Sync server state with clients using Charm", "description": "Simple server-to-client sync for Charm",
"repository": "littensy/charm",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",
"types": "src/index.d.ts", "types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": { "scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish" "publish:wally": "wally publish"
}, },
"peerDependencies": { "dependencies": {
"@rbxts/charm": "workspace:*" "@rbxts/charm": "workspace:*"
} }
} }

View file

@ -0,0 +1,139 @@
local Charm = require("../../Charm")
local client = require("../client")
local suite = require("../../../../test/suite")
local test = suite.test
local afterEach = suite.afterEach
afterEach(function()
client.removeAllSignals()
end)
test("should hydrate atom", function()
local a = Charm.atom(1)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = 2 } },
})
assert(a() == 2, "should set a to the initial value")
end)
test("should merge patch", function()
local a = Charm.atom({ b = 1, c = 2 })
local previous = a()
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = a() } },
})
assert(a() == previous, "should set a to the initial value")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a() ~= previous, "should apply patch")
assert(a().b == 3, "should set b to the new value")
assert(a().c == 2, "should not change c")
end)
test("should merge patch (reactive)", function()
local a = Charm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()
local _ = a.b + a.c
runs += 1
end)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = { b = a.b, c = a.c } } },
})
assert(runs == 1, "should not trigger effect for redundant init")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a.b == 3, "should set b to the new value")
assert(a.c == 2, "should not change c")
assert(runs == 2, "should trigger effect for patch")
end)
test("should fix sparse array patches", function()
local a = Charm.atom({ 1 })
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = a() } },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client.patch({
{ type = "patch", data = { a = { ["2"] = 2 } } },
})
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
end)
test("should merge multiple patches", function()
local a = Charm.atom(1)
local calls = {}
client.addSignals({ a = a })
Charm.subscribe(a, function(value)
table.insert(calls, value)
end)
client.patch({
{ type = "init", data = { a = a() } },
})
client.patch({
{ type = "patch", data = { a = 2 } },
{ type = "patch", data = { a = 3 } },
})
assert(a() == 3, "should apply the last patch")
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
end)
test("should batch patches", function()
local a = Charm.atom(0)
local b = Charm.atom(0)
local states = {}
client.addSignals({ a = a, b = b })
Charm.subscribe(a, function()
states.b = b()
end)
Charm.subscribe(b, function()
states.a = a()
end)
client.patch({
{ type = "init", data = { a = a(), b = b() } },
})
client.patch({
{ type = "patch", data = { a = 1, b = 2 } },
})
assert(a() == 1, "should set a to the new value")
assert(b() == 2, "should set b to the new value")
assert(states.a == 1, "sub to a should run after b updates")
assert(states.b == 2, "sub to b should run after a updates")
end)
return {}

View file

@ -0,0 +1,99 @@
local Charm = require("../../Charm")
local client = require("../client")
local config = require("../config")
local server = require("../server")
local suite = require("../../../../test/suite")
local afterEach = suite.afterEach
local test = suite.test
afterEach(function()
server.reset()
client.removeAllSignals()
config.preserveHistory = false
end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads)
if syncTarget == target then
client.patch(payloads)
end
end)
server.flush()
assert(clientA().count == 0, "Client atom A should be initialized to 0")
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
serverA({ count = 1 })
serverB.count = 1
server.flush()
assert(clientA().count == 1, "Client atom A should be updated to 1")
assert(clientB.count == 1, "Client reactive B should be updated to 1")
local runs = -1
Charm.effect(function()
local _ = clientB.count
runs += 1
end)
serverA({ count = 2 })
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(runs == 0, "Client reactive B should not trigger update")
end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
config.preserveHistory = true
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
server.connect(function(syncTarget, payloads)
if syncTarget == target then
client.patch(payloads)
end
end)
local expectedA = { -1, 0, 1, 2, 3, 4 }
local expectedB = { -1, 0, 1, 2 }
Charm.effect(function()
local expected = table.remove(expectedA, 1)
assert(expected == clientA().count, `Client reactive A should be {expected}`)
end)
Charm.effect(function()
local expected = table.remove(expectedB, 1)
assert(expected == clientB.count, `Client reactive B should be {expected}`)
end)
server.flush()
serverA({ count = 1 })
serverA({ count = 2 })
serverB.count = 1
serverB.count = 2
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB.count == 2, "Client reactive B should be updated to 2")
serverA({ count = 3 })
serverA({ count = 4 })
server.flush()
assert(clientA().count == 4, "Client atom A should be updated to 4")
end)
return {}

View file

@ -0,0 +1,130 @@
local config = require("../config")
local patch = require("../patch")
local suite = require("../../../../test/suite")
local test = suite.test
local afterEach = suite.afterEach
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then
local copy = {}
for index = 1, #object do
copy[index] = object[index]
end
return copy
elseif table.maxn(object) ~= #object then
local copy = {}
for key, value in object do
copy[tostring(key)] = value
end
return copy
else
return object
end
end
afterEach(function()
config.fixArrays = true
config.validatePatches = true
end)
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local diffs = patch.diff(prevState, nextState)
assert(diffs ~= nil, "should return a patch")
assert(diffs.baz == "quux", "should return the changed value")
assert(diffs.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local diffs = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, diffs)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
assert(applied.foo == "bar", "should keep the unchanged value")
assert(applied.qaz == "qux", "should add the new value")
end)
test("allows nested diffs", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local diffs = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, diffs)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
test("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
assert(applied[1] == "a", "should keep a")
assert(applied[2] == "B", "should apply B")
assert(applied[3] == "c", "should keep c")
assert(applied[4] == "D", "should apply D")
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
end)
test("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
for index in prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
for index in applied do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("cannot preserve sparse arrays", function()
config.validatePatches = false
local prevState = {}
local nextState = { [2] = "foo" }
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
-- Not enough information is available to determine whether 'diffs'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
end)
test("optionally fixes sparse arrays", function()
config.fixArrays = true
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
end
config.fixArrays = false
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
end
end)
test("patches mutable state", function()
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
assert(targetState.baz == "quux", "should apply the changed value")
assert(targetState.foo == "bar", "should keep the unchanged value")
assert(targetState.qaz == "qux", "should add the new value")
end)
return {}

View file

@ -0,0 +1,349 @@
local Charm = require("../../Charm")
local config = require("../config")
local patch = require("../patch")
local server = require("../server")
local suite = require("../../../../test/suite")
local afterEach = suite.afterEach
local beforeEach = suite.beforeEach
local describe = suite.describe
local test = suite.test
afterEach(function()
server.reset()
end)
test("should replicate state changes", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
a(1)
b(2)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'")
assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
end)
test("should replicate partial state patches", function()
local client = {}
local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?)
local target, payloads
server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'")
assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
a({ x = 1, y = 0 })
b(nil)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'")
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x == a.x, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'")
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
a.x = 1
a.z = nil
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain value 'a.x'")
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'")
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA, payloadsB
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
a(1)
server.flush()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
a(2)
server.addSignalsToClient(clientB, { a = a })
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
end)
test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local target, payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
server.addSignalsToClient(client, { b = b })
a(1)
b(1)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
assert(payloads[2].type == "patch", "Expected second payload to be patch")
assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
end)
test("should only replicate to synced clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloadsA, payloadsB
server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
server.flush()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
payloadsA = {}
payloadsB = {}
a(1)
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A")
assert(#payloadsB == 0, "Expected no payloads for client B")
payloadsA = {}
payloadsB = {}
b(1)
server.flush()
assert(#payloadsA == 0, "Expected no payloads for client A")
assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end)
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
a({ 1, 2, 0, 4 })
server.flush()
assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
end)
describe("historical", function()
beforeEach(function()
config.preserveHistory = true
end)
afterEach(function()
config.preserveHistory = false
end)
test("should replicate granular state changes", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
a(1)
a(2)
b(2)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = Charm.reactive({ value = 0 })
local b = Charm.reactive({ value = 0 })
local c = Charm.reactive({ value = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
a.value = 1
a.value = 2
b.value = 2
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA, payloadsB
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
a(1)
a(2)
server.flush()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
server.addSignalsToClient(clientB, { a = a })
a(3)
a(4)
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
end)
test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...)
payloads = ...
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
server.addSignalsToClient(client, { b = b })
a(1)
a(2)
b(1)
b(2)
server.flush()
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].type == "init", "Expected third payload to be init")
assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
end)
return {}

View file

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

View 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 {}

View file

@ -1,83 +1,105 @@
local Charm = require(script.Parent.Parent.Charm) local Charm = require("../Charm")
local patch = require(script.Parent.patch) local patch = require("./patch")
local types = require(script.Parent.types) local types = require("./types")
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ClientOptions = { type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
--[=[
The atoms to synchronize with the server.
]=]
atoms: AtomMap,
--[=[
Whether to ignore patches sent before the client has been hydrated.
Default is `true`.
]=]
ignoreUnhydrated: boolean?,
}
type ClientSyncer = { type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
--[=[
Applies a patch or initializes the state of the atoms with the given
payload from the server.
@param ...payloads The patches or hydration payloads to apply.
]=]
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--[=[ type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
Creates a `ClientSyncer` object that receives patches from the server and
applies them to the local state.
@param options The atoms to synchronize with the server.
@return A `ClientSyncer` object.
]=]
local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local self = {} :: ClientSyncer local trackedSignals: { [string]: SignalSetter } = {}
local hydrated = false local trackedReactives: { [string]: ReactiveSetter } = {}
local function hydrate(state: { [string | number]: any }) --[[
hydrated = true Registers a map of setters to the state with the corresponding keys on the
server. When the server sends updates, these setters will be called with
the new values.
for key, value in next, state do Atoms, signals converted to atoms, and signal setter functions are allowed.
local atom = atoms[key] Note that `server.addSignalsToClient` still requires some way to get the
atom(value) state.
@param signals A map of setter functions to sync with the server.
]]
local function addSignals(signals: { [string]: SignalSetter | { [any]: any } })
for key, signal in signals do
if type(signal) == "function" then
trackedSignals[key] = signal
else
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
end end
end end
end
local function apply(data: { [string | number]: any }) --[[
local target = {} Unregisters from server state updates for the given keys. The signals
will retain their current values, but will no longer receive updates
from the server.
]]
local function removeSignals(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
trackedSignals[key] = nil
trackedReactives[key] = nil
end
end
for key, atom in next, atoms do --[[
target[key] = atom() Unsubscribes from all server state updates.
end ]]
local function removeAllSignals()
table.clear(trackedSignals)
table.clear(trackedReactives)
end
target = patch.apply(target, data) local function applyInitPayload(data: { [string]: any })
for key, value in data do
for key, atom in next, atoms do local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key]
atom(target[key]) if trackedSignal then
trackedSignal(value)
end end
end end
end
function self:sync(...) local function applyPatchPayload(data: { [string]: any })
for index = 1, select("#", ...) do for key, diffs in data do
local payload: SyncPayload = select(index, ...) local trackedSignal = trackedSignals[key]
local trackedReactive = trackedReactives[key]
Charm.batch(function() if trackedSignal then
if payload.type == "init" then trackedSignal(function(value)
hydrate(payload.data) return patch.apply(value, diffs)
elseif not ignoreUnhydrated or hydrated then end)
apply(payload.data) elseif trackedReactive then
end trackedReactive(function(value)
patch.applyMutable(value, diffs)
end) end)
end end
end end
return self
end end
return client --[[
Merges the server's state patches into the client's state, either
initializing it with the full state or merging a partial patch.
@param payloads The state updates received from the server.
]]
local function patch(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
Charm.batch(applyInitPayload, payload.data)
elseif payload.type == "patch" then
Charm.batch(applyPatchPayload, payload.data)
end
end
end
return {
addSignals = addSignals,
removeSignals = removeSignals,
removeAllSignals = removeAllSignals,
patch = patch,
}

View file

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

View file

@ -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

View file

@ -1,214 +1,289 @@
import { Atom, Selector } from "@rbxts/charm"; import { Atom, Setter } from "@rbxts/charm";
export = CharmSync;
export as namespace CharmSync;
type Cleanup = () => void;
type Key = string | number | symbol; type Key = string | number | symbol;
declare namespace CharmSync { /**
type AtomMap = Record<string, Atom<any>>; * Represents the removal of a value from the state.
*/
export interface None {
readonly __none: "__none";
}
type SelectorMap = Record<string, Selector<any>>; export type MaybeNone<T> = undefined extends T ? None : never;
type ReactiveObject = { [key: Key]: any };
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
/**
* Infers the type of the return values produced by a map of functions.
*/
export type StateOfMap<T> = {
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
};
type DataTypes = {
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
};
type DataType = DataTypes[keyof DataTypes];
/**
* A partial patch that can be applied to the state to update it. Represents
* the difference between the current state and the next state.
*
* If a value was removed, it is replaced with `None`. This can be checked
* using the `isNone` function.
*/
// TODO: Optimize type, this looks overly complex
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
| ReadonlyMap<infer K, infer V>
| Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? FixArrays extends true
? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
: readonly (SyncPatch<T, FixArrays> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
: State;
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
| { type: "init"; data: StateOfMap<Getters> }
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
/**
* Global configuration options that affect the behavior of Charm Sync.
*/
export const config: {
/** /**
* @deprecated Use `SelectorMap` instead. * The interval at which to send patches to the client, in seconds.
*/ * Defaults to `0` (patches are sent up to once per frame). Set to a
type MoleculeMap = SelectorMap; * negative value to disable automatic syncing.
/**
* Infers the type of the return values produced by a map of functions.
*/
type StateOfMap<T> = {
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
};
/**
* Represents the removal of a value from the state.
*/
interface None {
readonly __none: "__none";
}
type MaybeNone<T> = undefined extends T ? None : never;
/**
* Creates a `ClientSyncer` object that receives patches from the server and
* applies them to the local state.
*
* @client
* @param options The atoms to synchronize with the server.
* @returns A `ClientSyncer` object.
*/
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
/**
* Creates a `ServerSyncer` object that sends patches to the client and
* hydrates the client's state.
* *
* @server * @server
* @param options The atoms to synchronize with the client.
* @returns A `ServerSyncer` object.
*/ */
function server<Selectors extends SelectorMap, Serialize extends boolean = true>( interval: number;
options: ServerOptions<Selectors, Serialize>,
): ServerSyncer<Selectors, Serialize>;
/** /**
* Checks whether a value is `None`. If `true`, the value is scheduled to be * Whether the history of state changes since the client's last update
* removed from the state when the patch is applied. * should be preserved. This is useful for values that change multiple
* times per frame, where each individual change is important. Defaults
* to `false`.
* *
* @param value The value to check. * @server
* @returns `true` if the value is `None`, otherwise `false`.
*/ */
function isNone(value: unknown): value is None; preserveHistory: boolean;
interface NestedAtomMap {
readonly [key: string]: NestedAtomMap | Atom<any>;
}
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
? Name
: Name extends string
? Path extends string
? `${Path}/${Name}`
: never
: never;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
: Atoms[Name] extends NestedAtomMap
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
: never;
}>;
/** /**
* Flattens a nested atom map into a single object with slash-separated * When `true`, Charm will apply validation and serialize unsafe arrays
* keys. Useful for recursively collecting atoms returned by modules. * to address remote event argument limitations. Defaults to `true`.
* This option should be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet) to prevent interference.
* *
* @param atoms The nested atom map to flatten. * @server
* @return A flattened atom map.
*/ */
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>; fixArrays: boolean;
type DataTypes = {
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
};
/** /**
* A type that should not be made partial in patches. * When `true`, synced state containing unsafe sparse arrays or mixed
*/ * tables will throw an error. Only checked in strict mode and if
type DataType = DataTypes[keyof DataTypes]; * `fixArrays` is enabled. Defaults to `true`.
/**
* A partial patch that can be applied to the state to update it. Represents
* the difference between the current state and the next state.
* *
* If a value was removed, it is replaced with `None`. This can be checked * @server
* using the `sync.isNone` function.
*/ */
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends validatePatches: boolean;
| ReadonlyMap<infer K, infer V> };
| Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V, Serialize> | None> /**
: State extends Set<infer T> | ReadonlySet<infer T> * @client
? ReadonlyMap<T, true | None> */
: State extends readonly (infer T)[] export namespace client {
? Serialize extends true /**
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None> * Registers a map of setters to the state with the corresponding keys on the
: readonly (SyncPatch<T, Serialize> | None | undefined)[] * server. When the server sends updates, these setters will be called with
: State extends DataType * the new values.
? State *
: State extends object * Atoms, signals converted to atoms, and signal setter functions are allowed.
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> } * Note that `server.addSignalsToClient` still requires some way to get the
: State; * state.
*
* @param signals A map of setter functions to sync with the server.
*/
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
/** /**
* A payload that can be sent from the server to the client to synchronize * Unregisters from server state updates for the given keys. The signals
* state between the two. * will retain their current values, but will no longer receive updates
* from the server.
*/ */
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> = export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
| { readonly type: "init"; readonly data: StateOfMap<Selectors> } ...keys: (keyof Signals)[]
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> }; ): void;
interface ClientOptions<Atoms extends AtomMap> { /**
/** * Unsubscribes from all server state updates.
* The atoms to synchronize with the server. */
*/ export function removeAllSignals(): void;
atoms: Atoms;
/**
* Whether to ignore patches sent before the client has been hydrated.
* Defaults to `true`.
*/
ignoreUnhydrated?: boolean;
}
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> { /**
/** * Merges the server's state patches into the client's state, either
* The atoms to synchronize with the client. * initializing it with the full state or merging a partial patch.
*/ *
atoms: Selectors; * @param payloads The state updates received from the server.
/** */
* The interval at which to send patches to the client, in seconds. export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
* Defaults to `0` (patches are sent up to once per frame). Set to a payloads: SyncPayload<Getters, FixArrays>[],
* negative value to disable automatic syncing. ): void;
*/
interval?: number;
/**
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple times
* per frame, where each individual change is important. Defaults to `false`.
*
* If `true`, the broadcaster will send a list of payloads to the client
* instead of a single payload. The client will apply each payload in order
* to reconstruct the state's changes over time.
*/
preserveHistory?: boolean;
/**
* When `true`, Charm will apply validation and serialize unsafe arrays
* to address remote event argument limitations. Defaults to `true`.
*
* This option should be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet) to prevent interference.
*/
autoSerialize?: Serialize;
}
interface ClientSyncer<Atoms extends AtomMap> {
/**
* Applies a patch or initializes the state of the atoms with the given
* payload from the server.
*
* @param payloads The patches or hydration payloads to apply.
*/
sync(...payloads: SyncPayload<Atoms, boolean>[]): void;
}
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
/**
* Sets up a subscription to each atom that schedules a patch to be sent to
* the client whenever the state changes. When a change occurs, the `callback`
* is called with the player and the payloads to send.
*
* Note that a `payload` object should not be mutated. If you need to modify
* a payload, apply the changes to a copy of the object.
*
* @param callback The function to call when the state changes.
* @returns A cleanup function that unsubscribes all listeners.
*/
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors, Serialize>[]) => void): Cleanup;
/**
* Hydrates the client's state with the server's state. This should be
* called when a player joins the game and requires the server's state.
*
* @param player The player to hydrate.
*/
hydrate(player: Player): void;
}
} }
/**
* @server
*/
export namespace server {
/**
* Subscribes a client to the given signals. When an update occurs, the client
* will receive a partial state patch to merge into their local state. May be
* called multiple times to subscribe to additional keys.
*
* Atoms, computed signals, and signal getter functions are allowed. Note
* that `client.addSignals` still requires some way to set the state.
*
* @param client The client receiving state updates.
* @param signals A map of getter functions to sync with the client.
*/
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
signals: Signals,
): void;
/**
* Unsubscribes a client from receiving all state updates. To only unsubscribe
* from specific keys, use `removeSignalsFromClient` instead.
*
* @param client The client receiving state updates.
*/
export function removeClient(client: Player): void;
/**
* Unsubscribes a client from receiving updates for the given keys.
*
* @param client The client receiving state updates.
* @param keys The keys of the state to unsubscribe from.
*/
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
client: Player,
...keys: (keyof Signals)[]
): void;
/**
* Uses the callback to send state updates to clients that are subscribed
* to state changes. Starts automatically sending updates at the interval
* specified in `config.interval`.
*
* @param onSync Called when sending patches to a client.
*/
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
): void;
/**
* Stops syncing state updates to clients at the automatic interval.
* Flushing can still be performed manually via `flush`.
*/
export function disconnect(): void;
/**
* Immediately sends pending updates to all clients. Normally, updates are
* sent at the interval specified in `config.interval`, but this function
* can be used to force an immediate sync.
*/
export function flush(): void;
/**
* Unsubscribes all clients, stops tracking all state, and disconnects
* from the automatic sync interval.
*/
export function reset(): void;
}
export namespace patch {
/**
* Converts `nil` values to the special `None` symbol. This is useful when
* preparing data for serialization, since `nil` can represent both the
* absence of a value and the removal of a value in patches.
*/
export function nilToNone<T>(value: T | undefined): T | None;
/**
* Compares two states and returns a patch that can be applied to the
* current state to produce the next state.
*
* @param currentState The current state.
* @param nextState The next state.
* @returns A patch that can be applied to the current state.
*/
export function diff<State, FixArrays extends boolean = true>(
currentState: State,
nextState: State,
): SyncPatch<State, FixArrays>;
/**
* Applies a patch to a state and returns the resulting value.
*
* @param currentState The current state.
* @param statePatch The patches to apply.
* @returns The new state with the patch applied.
*/
export function apply<State, FixArrays extends boolean = true>(
currentState: State,
statePatch: SyncPatch<State, FixArrays>,
): State;
}
type DeepFlattenImpl<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends DataType | Callback
? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
: T[K] extends object
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
}[keyof T];
type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
/**
* Flattens a nested table into a single table with slash-separated keys.
* Useful for recursively collecting atoms returned by modules.
*
* @param input A nested table.
* @return A flattened table with slash-separated keys.
*/
export function flatten<T>(input: T): DeepFlatten<T>;
/**
* Checks whether a value is `None`. If `true`, the value is scheduled to be
* removed from the state when the patch is applied.
*
* @param value The value to check.
* @returns `true` if the value is `None`, otherwise `false`.
*/
export function isNone(value: unknown): value is None;
/**
* Converts a signal getter and setter into an atom. This is useful for
* syncing client signals with the server, as the sync system requires both
* the getter and setter to update state.
*
* @param getter A function that returns the current value of the signal.
* @param setter A function that sets the value of the signal.
* @returns An atom that can either get or set the signal's value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
*/
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;

View file

@ -1,18 +1,19 @@
local client = require(script.client) local client = require("@self/client")
local flatten = require(script.flatten) local config = require("@self/config")
local patch = require(script.patch) local patch = require("@self/patch")
local server = require(script.server) local server = require("@self/server")
local types = require(script.types) local types = require("@self/types")
local utils = require("@self/utils")
export type None = types.None
export type SyncPayload = types.SyncPayload export type SyncPayload = types.SyncPayload
--[=[
Synchronizes state between the client and server. The server sends patches
to the client, which applies them to its local state.
]=]
return { return {
config = config,
client = client, client = client,
server = server, server = server,
flatten = flatten, patch = patch,
isNone = patch.isNone, isNone = patch.isNone,
flatten = utils.flatten,
signalToAtom = utils.signalToAtom,
} }

View file

@ -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

View file

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

View file

@ -1,267 +1,413 @@
local Players = game:GetService("Players") local Charm = require("../Charm")
local config = require("./config")
local patch = require("./patch")
local types = require("./types")
local utils = require("./utils")
local Charm = require(script.Parent.Parent.Charm)
local interval = require(script.Parent.interval)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ServerOptions = { type SyncStatus = number
--[=[
The atoms to synchronize with the client.
]=]
atoms: AtomMap,
--[=[
The interval at which to send patches to the client, in seconds.
Defaults to `0` (patches are sent up to once per frame). Set to a
negative value to disable automatic syncing.
]=]
interval: number?,
--[=[
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple times
per frame, where each individual change is important. Defaults to `false`.
]=]
preserveHistory: boolean?,
--[=[
When `true`, Charm will apply validation and serialize unsafe arrays
to address remote event argument limitations. Defaults to `true`.
This option should be disabled if your network library uses a custom type ClientKeys = { [string]: SyncStatus }
serialization method (i.e. Zap, ByteNet) to prevent interference.
]=] type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
autoSerialize: boolean?,
type TrackedState<T = any> = {
target: any,
currentValue: T,
lastSyncedValue: T,
dispose: () -> (),
isReactive: boolean?,
} }
type ServerSyncer = { type StateUpdate = {
--[=[ key: string,
Sets up a subscription to each atom that schedules a patch to be sent to newValue: any,
the client whenever the state changes. When a change occurs, the `callback` oldValue: any,
is called with the player and the payload to send.
Note that the `payload` object should not be mutated. If you need to
modify the payload, apply the changes to a copy of the object.
@param callback The function to call when the state changes.
@return A cleanup function that unsubscribes all listeners.
]=]
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--[=[
Hydrates the client's state with the server's state. This should be
called when a player joins the game and requires the server's state.
@param player The player to hydrate.
]=]
hydrate: (self: ServerSyncer, player: Player) -> (),
--[=[
@deprecated For internal use only.
]=]
_sendPatch: (self: ServerSyncer, player: Player) -> (),
} }
--[=[ local PENDING_INITIAL_STATE: SyncStatus = 0
Creates a `ServerSyncer` object that sends patches to the client and local LISTENING_FOR_CHANGES: SyncStatus = 1
hydrates the client's state.
@param options The atoms to synchronize with the client.
@return A `ServerSyncer` object.
]=]
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local autoSerialize = options.autoSerialize ~= false
local preserveHistory = options.preserveHistory
local syncRate = options.interval or 0
local self = {} :: ServerSyncer local trackedStates: { [string]: TrackedState } = {}
local sync: (player: Player, payload: SyncPayload) -> () local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {}
local function createSnapshot() local connection: RBXScriptConnection?
local snapshot = {} local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
for key, atom in next, atoms do local function trackSignal<T>(key: string, getter: () -> T)
snapshot[key] = atom() if trackedStates[key] then
-- Ignore duplicates, but warn against name collisions
if trackedStates[key].target ~= getter then
warn("[Charm] Attempted to sync different states with the same key:", key)
end end
return
return snapshot
end end
-- Send the initial state to a player when they join the server. local value = getter()
function self:hydrate(player) local trackedState = {
assert(sync, "connect() must be called before hydrate()") target = getter,
currentValue = value,
lastSyncedValue = value,
}
sync(player, { trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
type = "init", -- When preserving history, we need to keep track of previous values
data = createSnapshot(), -- so we can create granular patches for each change.
}) if config.preserveHistory then
table.insert(globalUpdates, {
key = key,
newValue = newValue,
oldValue = oldValue,
})
end
trackedState.currentValue = newValue
end)
trackedStates[key] = trackedState
end
local function trackReactive<T>(key: string, reactive: Reactive<T>)
if trackedStates[key] then
if trackedStates[key].target ~= reactive then
warn("[Charm] Attempted to sync different states with the same key:", key)
end
return
end end
-- If the history of state changes should be preserved, keep a list of local meta = getmetatable(reactive)
-- previous snapshots and diff-check each one from the last. local snapshot = utils.deepCopy(meta.initialValue)
if preserveHistory then local firstRun = true
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback) local trackedState = {
local subscriptions = {} target = reactive,
currentValue = meta.initialValue,
lastSyncedValue = snapshot,
isReactive = true,
}
sync = callback trackedState.dispose = Charm.effect(function()
meta.track()
local function pushSnapshot(key: string | number, current: any, previous: any) if not config.preserveHistory or firstRun then
local lastSnapshot = snapshots[#snapshots] firstRun = false
local previousSnapshot = snapshots[#snapshots - 1] else
local oldSnapshot = snapshot
-- Optimize snapshots by updating the most recent snapshot if the snapshot = utils.deepCopy(meta.initialValue)
-- previous and current values are the same, since this allows us table.insert(globalUpdates, {
-- to group multiple changes into a single snapshot. key = key,
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then newValue = snapshot,
lastSnapshot[key] = current oldValue = oldSnapshot,
else })
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
end
end
-- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in next, snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, unpack(payloads))
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end end
end)
function self:_sendPatch(player: Player) trackedStates[key] = trackedState
assert(sync, "connect() must be called before _sendPatch()") end
if not changed then local function stopTracking(key: string)
return local trackedState = trackedStates[key]
end if trackedState then
trackedState.dispose()
trackedStates[key] = nil
end
end
local payloads: { SyncPayload } = {} local function purgeUntracked()
local active: { [string]: boolean } = {}
for index, snapshot in next, snapshots do for _, clientKeys in clients do
if index == 1 then for key in clientKeys do
continue active[key] = true
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, unpack(payloads))
end end
return self
end end
local previousSnapshot = createSnapshot() for key in trackedStates do
local currentSnapshot = table.clone(previousSnapshot) if not active[key] then
local changed = false stopTracking(key)
end
end
end
function self:connect(callback) local function diffGlobalUpdateBuffer(): { any }
local subscriptions = {} local patchCount = #globalUpdates
local patches = table.create(patchCount)
sync = callback for index, update in globalUpdates do
patches[index] = patch.diff(update.oldValue, update.newValue)
end
-- Subscribe to each atom and update the state when one changes. return patches
for key, atom in next, atoms do end
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
changed = true local clientKeys = clients[client]
end)
if not clientKeys then
return nil
end
local payloads: { SyncPayload } = {}
-- Iterate over all global updates and see if the client is interested in
-- any of them. If so, we create a patch payload for the update.
for index, update in globalUpdates do
local patchData = globalPatches[index]
if not patchData then
continue
end end
local disconnect = interval(function() local key = update.key
if not changed then local status = clientKeys[key]
return
end
local payload: SyncPayload = { if status == LISTENING_FOR_CHANGES then
table.insert(payloads, {
type = "patch", type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), data = { [key] = patchData },
} })
elseif status == PENDING_INITIAL_STATE then
table.insert(payloads, {
type = "init",
data = { [key] = update.newValue },
})
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
previousSnapshot = table.clone(currentSnapshot) -- If a pending signal was never updated, we still need to send its initial
changed = false -- state. This can happen if a signal was registered after its last change.
local initData: { [string]: any } = {}
for _, player in next, Players:GetPlayers() do for key, status in clientKeys do
callback(player, payload) local trackedState = trackedStates[key]
end
end, syncRate)
return function() if status == PENDING_INITIAL_STATE and trackedState then
disconnect() initData[key] = trackedState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
for _, unsubscribe in next, subscriptions do if next(initData) ~= nil then
unsubscribe() table.insert(payloads, 1, { type = "init" :: "init", data = initData })
end
return payloads
end
local function diffGlobalState(): any
local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {}
for key, trackedState in trackedStates do
local currentValue = if trackedState.isReactive
then utils.deepCopy(trackedState.currentValue)
else trackedState.currentValue
currentGlobalState[key] = currentValue
previousGlobalState[key] = trackedState.lastSyncedValue
trackedState.lastSyncedValue = currentValue
end
return patch.diff(previousGlobalState, currentGlobalState)
end
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
return nil
end
local payloads: { SyncPayload } = {}
local initData: { [string]: any } = {}
local patchData: { [string]: any } = {}
-- Create patches for initialized signals that changed, and add signals
-- that are still pending initial state to the init payload.
for key, status in clientKeys do
local trackedState = trackedStates[key]
if status == LISTENING_FOR_CHANGES then
patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
if next(initData) ~= nil then
table.insert(payloads, { type = "init", data = initData })
end
if next(patchData) ~= nil then
table.insert(payloads, { type = "patch", data = patchData })
end
return payloads
end
--[[
Subscribes a client to the given signals. When an update occurs, the client
will receive a partial state patch to merge into their local state. May be
called multiple times to subscribe to additional keys.
Atoms, signals converted to atoms, and signal getter functions are allowed.
Note that `client.addSignals` still requires some way to set the state.
@param client The client receiving state updates.
@param signals A map of getter functions to sync with the client.
]]
local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any })
if not clients[client] then
clients[client] = {}
end
local clientKeys = clients[client]
for key, signal in signals do
if type(signal) == "function" then
trackSignal(key, signal)
else
trackReactive(key, signal :: any)
end
if not clientKeys[key] then
-- Mark the signal as pending initial state so we send its full
-- value the next time we sync.
clientKeys[key] = PENDING_INITIAL_STATE
end
end
end
--[[
Unsubscribes a client from receiving all state updates. To only unsubscribe
from specific keys, use `unsubscribeClientFrom` instead.
@param client The client receiving state updates.
]]
local function removeClient(client: any)
clients[client] = nil
end
--[[
Unsubscribes a client from receiving updates for the given keys.
@param client The client receiving state updates.
@param keys The keys of the state to unsubscribe from.
]]
local function removeSignalsFromClient(client: any, ...: string)
local clientKeys = clients[client]
if not clientKeys then
return
end
for index = 1, select("#", ...) do
local key = select(index, ...)
clientKeys[key] = nil
end
if next(clientKeys) == nil then
removeClient(client)
end
end
--[[
Immediately sends pending updates to all clients. Normally, updates are
sent at the interval specified in `config.interval`, but this function
can be used to force an immediate sync.
]]
local function flush()
if not syncCallback then
return
end
local granular = config.preserveHistory
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
for client in clients do
local payloads = if granular
then selectFromGlobalPatchList(client, globalPatch)
else selectFromGlobalPatch(client, globalPatch)
if payloads and payloads[1] then
if task then
task.spawn(syncCallback, client, payloads)
else
syncCallback(client, payloads)
end end
end end
end end
function self:_sendPatch(player) purgeUntracked()
assert(sync, "connect() must be called before _sendPatch()") table.clear(globalUpdates)
end
if not changed then --[[
Uses the callback to send state updates to clients that are subscribed
to state changes. Starts automatically sending updates at the interval
specified in `config.interval`.
@param onSync Called when sending patches to a client.
]]
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
if connection then
connection:Disconnect()
end
syncCallback = onSync
if not game then
return
end
local timer = 0
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
if config.interval < 0 then
return return
end end
local payload: SyncPayload = { timer += deltaTime
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot) if timer >= config.interval then
changed = false timer = 0
debug.profilebegin("CharmSync")
sync(player, payload) flush()
end debug.profileend()
end
return self end)
end end
return server --[[
Stops syncing state updates to clients at the automatic interval.
Flushing can still be performed manually via `flush`.
]]
local function disconnect()
if connection then
connection:Disconnect()
connection = nil
end
end
--[[
Unsubscribes all clients, stops tracking all state, and disconnects
from the automatic sync interval.
]]
local function reset()
table.clear(trackedStates)
table.clear(clients)
table.clear(globalUpdates)
disconnect()
end
return {
addSignalsToClient = addSignalsToClient,
removeClient = removeClient,
removeSignalsFromClient = removeSignalsFromClient,
connect = connect,
disconnect = disconnect,
flush = flush,
reset = reset,
}

View file

@ -1,20 +1,21 @@
local Charm = require(script.Parent.Parent.Charm) --[[
type Atom<T> = Charm.Atom<T>
--[=[
A payload that can be sent from the server to the client to synchronize A payload that can be sent from the server to the client to synchronize
state between the two. the states of the client's atoms with the server.
]=] ]]
export type SyncPayload = { export type SyncPayload =
type: "init", | {
data: { [string | number]: any }, type: "init",
} | { data: { [string]: any },
type: "patch", }
data: { [string | number]: any }, | {
} type: "patch",
data: { [string]: any },
}
export type AtomMap = { --[[
[string | number]: Atom<any>, A special type that represents a removed value in a patch. This is used to
} differentiate between a value that was set to `nil` and an unchanged value.
]]
export type None = { __none: "__none" }
return nil return {}

View file

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

View file

@ -1,67 +1,48 @@
local SAFE_KEYS = { string = true, number = true }
local UNSAFE_VALUES = { ["function"] = true, thread = true }
local function isTableUnsafe(object: { [any]: any }) local function isTableUnsafe(object: { [any]: any })
local keyType = nil local key = next(object)
local objectSize = 0
-- All keys must have the same type if key == nil then
for key in next, object do return false
local currentType = type(key)
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
return true
end
objectSize += 1
end end
-- If there are more keys than the length of the array, it's an array with local keyType = type(key)
-- non-sequential keys. local maxKey = 0
if objectSize > #object and keyType == "number" then
-- Keys must all be the same type
for key in object do
if type(key) ~= keyType then
return true
elseif keyType == "number" and key > maxKey then
maxKey = key
end
end
-- Array keys must be sequential
if keyType == "number" and maxKey > #object then
return true return true
end end
return false return false
end end
--[=[ --[[
Validates a value to ensure it can be synced over a remote event. Emits a warning if the value is a table that cannot be synced via
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
with remote argument limitations, such as sparse arrays or mixed tables.
@param value The value to validate. @param value The table value.
@param key The key of the value in the table. @param key The key of the value.
@error Throws an error if the value cannot be synced. ]]
]=] local function validate(value: { [any]: any })
local function validate(value: any, key: any) if isTableUnsafe(value) then
local typeOfKey = type(key) warn(
local typeOfValue = type(value) `Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
.. "This can be for the following reasons:\n"
if not SAFE_KEYS[typeOfKey] then .. "1. The table is a sparse array with non-sequential indices\n"
error(`Invalid key type '{typeOfKey}' at key '{key}'`) .. "2. The table is a mixed table (not all keys have the same type)\n\n"
elseif UNSAFE_VALUES[typeOfValue] then .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
error(`Invalid value type '{typeOfValue}' at key '{key}'`) value
elseif typeOfValue == "table" then )
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The object is an array with non-sequential keys\n"
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
)
end
end
if typeOfKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key")
end
end end
end end

View file

@ -1,8 +1,15 @@
[package] [package]
name = "littensy/charm-sync" name = "littensy/charm-sync"
version = "0.3.0" version = "0.4.0-rc.2"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [
"node_modules",
"src/__tests__",
"src/index.d.ts",
"package.json",
"Charm.luau",
]
[dependencies] [dependencies]
charm = "littensy/charm@^0.10" Charm = "littensy/charm@0.11.0-rc.2"

View file

@ -1,5 +1,5 @@
{ {
"name": "Charm", "name": "charm",
"tree": { "tree": {
"$path": "src" "$path": "src"
} }

View file

@ -1,11 +1,22 @@
{ {
"name": "@rbxts/charm", "name": "@rbxts/charm",
"version": "0.10.0", "version": "0.11.0-rc.2",
"description": "An atomic state management library for Roblox", "description": "Atomic state management based on alien-signals",
"repository": "littensy/charm",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",
"types": "src/index.d.ts", "types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": { "scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .", "prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish" "publish:wally": "wally publish"

View 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 {}

View 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 {}

View 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 {}

View file

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

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View file

@ -0,0 +1,124 @@
local Charm = require("../")
local suite = require("../../../../test/suite")
local effect = Charm.effect
local reactive = Charm.reactive
local test = suite.test
test("should track nested properties", function()
local src, setSrc = reactive({ a = { b = { c = 0 } } })
local calls = {}
effect(function()
table.insert(calls, `{src.a.b.c}`)
end)
setSrc(function(state)
state.a.b.c = 1
end)
src.a.b.c = 2
src.a.b.c = 2
setSrc()
assert(table.concat(calls) == "012", table.concat(calls))
end)
test("should track object type changes", function()
local src = reactive({ a = { b = 0 } } :: { a: any })
local calls = {}
effect(function()
table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
end)
src.a = { b = 0 }
src.a = 1
src.a = { b = 2 }
src.a = nil
src.a = 3
assert(table.concat(calls) == "0012nil3", table.concat(calls))
end)
test("should unwrap proxy assignments", function()
local src = reactive({ a = { x = 0 }, b = { x = 0 } })
local runs = 0
effect(function()
local _, _ = src.a.x, src.b.x
runs += 1
end)
src.a, src.b = src.a, src.b
assert(runs == 1, runs)
src.a, src.b = src.b, src.a
assert(runs == 3, runs)
end)
test("should track iteration", function()
local src, setSrc = reactive({} :: { [string]: number })
local calls = {}
effect(function()
for key, value in src do
table.insert(calls, `{key}={value};`)
end
end)
src.a = 1
assert(table.concat(calls) == "a=1;", table.concat(calls))
table.clear(calls)
src.b = 2
assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
table.clear(calls)
src.a = 2
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
table.clear(calls)
setSrc()
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
end)
test("should track array length", function()
local src = reactive({ array = {} :: { number } })
local calls = {}
effect(function()
table.insert(calls, #src.array)
end)
src.array[#src.array + 1] = 1
src.array[#src.array + 1] = 2
src.array[#src.array + 1] = 3
src.array = { 1, 2, 3, 4 }
assert(table.concat(calls) == "01234", table.concat(calls))
end)
test("should not track unread properties", function()
local src = reactive({ value = { a = 1, b = 2 } })
local calls = 0
effect(function()
local _ = src.value.a
calls += 1
end)
assert(calls == 1, calls)
src.value.b = 3
assert(calls == 1, calls)
end)
test("should overwrite with table passed to setter", function()
local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number })
local calls = {}
effect(function()
table.insert(calls, `{src.a}{src.b}{src.c}`)
end)
setSrc({ a = 2, c = 4 })
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
end)
return {}

View 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 {}

View file

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

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,187 +1,319 @@
export = Charm; type Key = string | number | symbol;
export as namespace Charm;
type Cleanup = () => void; type Cleanup = () => void;
declare namespace Charm { export enum ReactiveFlags {
/** None = 0b0000000,
* A primitive state container that can be read from or written to. When Mutable = 0b0000001,
* the state changes, all subscribers are notified. Watching = 0b0000010,
* RecursedCheck = 0b0000100,
* @param state The next state or a function that produces the next state. Recursed = 0b0001000,
* @returns The current state. Dirty = 0b0010000,
*/ Pending = 0b0100000,
interface Atom<State> extends Selector<State> { Peeking = 0b1000000,
(nextState: State | ((prevState: State) => State)): State;
}
/**
* A function that depends on one or more atoms and produces a value. Can be
* used to derive state from atoms.
*
* @returns The current state.
*/
type Selector<State> = () => State;
/**
* A function that depends on one or more atoms and produces a value. Can be
* used to derive state from atoms.
*
* @deprecated Use `Selector<T>` instead.
* @returns The current state.
*/
type Molecule<State> = Selector<State>;
/**
* Infers the type of the state produced by the given function.
*/
type StateOf<T> = T extends Selector<infer State> ? State : never;
interface AtomOptions<State> {
/**
* A function that determines whether the state has changed. By default,
* a strict equality check (`===`) is used.
*/
equals?: (prev: State, next: State) => boolean;
}
/**
* Creates a new atom with the given state.
*
* @param state The initial state.
* @param options Optional configuration.
* @returns A new atom.
*/
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
// Overload for no arguments
function atom<State>(state?: State, options?: AtomOptions<State>): Atom<State | undefined>;
/**
* Creates a read-only atom that derives its state from one or more atoms.
* Used to avoid unnecessary recomputations if multiple listeners depend on
* the same atoms.
*
* @param callback The function that produces the state.
* @param options Optional configuration.
* @returns A new read-only atom.
*/
function computed<State>(callback: Selector<State>, options?: AtomOptions<State>): Selector<State>;
/**
* Subscribes to changes in the given atom or selector. The callback is
* called with the current state and the previous state immediately after a
* change occurs.
*
* @param callback The atom or selector to subscribe to.
* @param listener The function to call when the state changes.
* @returns A function that unsubscribes the callback.
*/
function subscribe<State>(callback: Selector<State>, listener: (state: State, prev: State) => void): Cleanup;
/**
* Runs the given callback immediately and whenever any atom it depends on
* changes. Returns a cleanup function that unsubscribes the callback.
*
* @param callback The function to run.
* @returns A function that unsubscribes the callback.
*/
function effect(callback: () => Cleanup | void): Cleanup;
/**
* Returns the result of the function without subscribing to changes. If a
* non-function value is provided, it is returned as is.
*
* @param callback The atom or selector to get the state of.
* @param args Arguments to pass to the function.
* @returns The current state.
*/
function peek<State, Args extends unknown[]>(callback: State | ((...args: Args) => State), ...args: Args): State;
/**
* Returns whether the given value is an atom.
*
* @param value The value to check.
* @returns `true` if the value is an atom, otherwise `false`.
*/
function isAtom(value: unknown): value is Atom<any>;
/**
* Runs the given function and schedules listeners to be notified only once
* after the function has completed. Useful for batching multiple changes.
*
* @param callback The function to run.
*/
function batch(callback: () => void): void;
/**
* Captures all atoms that are read during the function call and returns them
* along with the result of the function. Useful for tracking dependencies.
*
* @param callback The function to run.
* @returns A tuple containing the captured atoms and the result of the function.
*/
function capture<State>(callback: Selector<State>): LuaTuple<[dependencies: Set<Atom<unknown>>, state: State]>;
/**
* Notifies all subscribers of the given atom that the state has changed.
* Mainly for running effects, since subscriptions check for equality.
*
* @param atom The atom to notify.
*/
function notify<State>(atom: Atom<State>): void;
/**
* Creates an instance of `factory` for each item in the atom's state, and
* cleans up the instance when the item is removed. Returns a cleanup function
* that unsubscribes all instances.
*
* @param callback The atom or selector to observe.
* @param factory The function that tracks the lifecycle of each item.
* @returns A function that unsubscribes all instances.
*/
function observe<Item>(
callback: Selector<readonly Item[]>,
factory: (item: Item, index: number) => Cleanup | void,
): Cleanup;
function observe<Key, Item>(
callback: Selector<Map<Key, Item> | ReadonlyMap<Key, Item>>,
factory: (item: Item, key: Key) => Cleanup | void,
): Cleanup;
function observe<Key extends string | number | symbol, Item>(
callback: Selector<{ readonly [P in Key]: Item }>,
factory: (item: Item, key: Key) => Cleanup | void,
): Cleanup;
/**
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
* function returns `undefined`, the entry is omitted from the resulting map.
* When the atom changes, the `mapper` is called for each entry in the state
* to compute the new state.
*
* @param callback The atom or selector to map.
* @param mapper The function that maps each entry.
* @returns A new atom with the mapped state.
*/
function mapped<V0, K1, V1>(
callback: Selector<readonly V0[]>,
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
): Selector<ReadonlyMap<K1, V1>>;
function mapped<V0, V1>(
callback: Selector<readonly V0[]>,
mapper: (value: V0, index: number) => V1,
): Selector<readonly V1[]>;
function mapped<K0, V0, K1 = K0, V1 = V0>(
callback: Selector<Map<K0, V0> | ReadonlyMap<K0, V0>>,
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
): Selector<ReadonlyMap<K1, V1>>;
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
callback: Selector<{ readonly [P in K0]: V0 }>,
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
): Selector<ReadonlyMap<K1, V1>>;
} }
export interface ReactiveNode {
deps?: Link;
depsTail?: Link;
subs?: Link;
subsTail?: Link;
flags: ReactiveFlags;
}
export interface Link {
version: number;
dep: ReactiveNode;
sub: ReactiveNode;
prevSub?: Link;
nextSub?: Link;
prevDep?: Link;
nextDep?: Link;
}
export interface Atom<T> {
(newValue: T | ((currentValue: T) => T)): T;
(): T;
}
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
export type Equals<T> = (current: T, incoming: T) => boolean;
/**
* Global flags that modify the behavior of Charm's reactive system.
*/
export const flags: {
/**
* Enforces synchronous, non-yielding behavior in signals and effects.
* Also enables state validation in Charm Sync to catch sync errors early.
* Enabled if the optimization level is below 2, which is true in studio.
*/
strict: boolean;
/**
* Enforces immutability of tables by deep-freezing table values. Enabled
* if the optimization level is below 2, which is true in studio.
*/
frozen: boolean;
/**
* Whether parent effects should track inner effects and clean them up
* when the parent effect re-runs. This is usually desirable, but can be
* disabled to revert to the old behavior. Defaults to `true`.
*/
trackInnerEffects: boolean;
};
/**
* Creates a reactive signal that stores a value and notifies subscribers when
* the value changes. The signal provides both a getter and a setter.
*
* @param initialValue The initial value of the signal.
* @param equals A comparator function to determine if the signal's value has changed.
* @returns A tuple containing the getter and setter functions for the signal.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
*/
export function signal<T>(
initialValue: T,
equals?: Equals<T>,
): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>;
export function signal<T>(): LuaTuple<
[getter: () => T | undefined, setter: (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined]
>;
/**
* Creates a reactive atom that acts as both a signal getter and setter.
* Calling the atom with an argument sets its value, while calling it with no
* arguments returns its current value.
*
* @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
* @param equals A comparator function to determine if the atom's value has changed.
* @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
*/
export function atom<T>(initialValue: T, equals?: Equals<T>): Atom<T>;
export function atom<T>(): Atom<T | undefined>;
/**
* Creates a new read-only signal that is computed based on the values of
* other signals. The computed signal's value is automatically updated when a
* change is detected in any of its dependencies.
*
* @param getter A function that produces the next value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
*/
export function computed<T>(getter: (previousValue?: T) => T): () => T;
/**
* Creates an effect that runs a callback in response to signal state changes.
* An effect tracks which signals are accessed during its execution, and runs
* the callback when those signals change.
*
* The effect callback may return a cleanup function, which gets called once,
* either before the effect re-runs or when it is disposed.
*
* If the effect is called within another effect, it will be disposed when the
* parent effect re-runs.
*
* @param fn The function to run when dependencies change.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
*/
export function effect(fn: () => Cleanup | void): Cleanup;
/**
* Creates an effect scope that can capture reactive effects and computed
* signals created within it so that they can be disposed together.
*
* @param fn A function that may create reactive effects.
* @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
* @returns A function for disposing all effects created within the scope.
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
*/
export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup;
/**
* Allows you to manually trigger updates for downstream dependencies when
* you've directly mutated a signal's value without using the signal setter.
*
* @param fn The dependency or a function that calls multiple dependencies.
* @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
*/
export function trigger(fn: () => void): void;
/**
* Binds the cleanup function to the current active effect or effect scope. If
* there is no active effect, a warning is logged, unless `failSilently` is
* set to `true`.
*
* @param fn The cleanup function to register.
* @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
* @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
*/
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
/**
* Runs the function without subscribing to signal updates or capturing inner
* effects in the parent effect or scope.
*
* For an alternative that still cleans up inner effects, use `peek` instead.
*
* @param fn A function that may read signals or create effects.
* @param args Arguments to pass to the function.
* @returns The return value of the function.
* @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
*/
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the function without subscribing to signal updates, but still captures
* inner effects so that they dispose with the parent effect.
*
* To avoid capturing inner effects, use `untracked` instead.
*
* @param fn A function that may read signals.
* @param args Arguments to pass to the function.
* @returns The return value of the function.
* @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
*/
export function peek<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Combines multiple updates into a single "commit" that runs effects and
* computed signals after the provided function finishes running.
*
* @param fn A function that performs multiple updates.
* @param args Arguments to pass to the function.
* @returns The return value of the function.
* @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
*/
export function batch<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* Creates an effect that runs the callback one immediately, and then again
* when the value returned by the getter changes. The callback receives the new
* value and the previous value as arguments.
*
* Effects created within the callback are cleaned up when the listener runs
* or the listener is disposed.
*
* @param getter A function that returns the value to watch.
* @param fn The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/
export function listen<T>(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup;
/**
* Creates an effect that only runs the callback when the value returned by the
* getter changes. The callback receives the new value and the previous value
* as arguments.
*
* Effects created within the callback are cleaned up when the listener runs
* or the listener is disposed.
*
* @param getter A function that returns the value to watch.
* @param fn The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
*/
export function subscribe<T>(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup;
/**
* Creates a new read-only signal that is computed by mapping over the entries
* of an existing atom. If the key is omitted from the mapper's return value,
* the original key is preserved.
*
* @param getter Returns the table to map over.
* @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
*/
export function mapped<VI, KI, VO, KO = KI>(
getter: () => Map<KI, VI> | ReadonlyMap<KI, VI>,
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): () => ReadonlyMap<KO, VO>;
// Overload for arrays
export function mapped<VI, VO, K extends Key = number>(
getter: () => readonly VI[],
mapper: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
): () => K extends number ? readonly VO[] : { readonly [P in K]: VO };
// Overload for objects
export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
getter: () => { readonly [K in KI]: VI },
mapper: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): () => { readonly [K in KO]: VO };
/**
* Calls the callback function for each unique key in the table returned by the
* getter. The callback is called once for each key, and again when a new key
* is added.
*
* The callback may return a cleanup function, which will be called when the
* key is removed or when the entire callback is disposed.
*
* Effects created within the callback are cleaned up when the item is removed
* from the table or when the callback is disposed.
*
* @param getter A function that returns the table to observe.
* @param fn A function that is called for each key in the table.
* @returns A function for disposing the observer.
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
*/
export function observe<V, K = number>(
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
fn: (value: V, key: K) => Cleanup | void,
): Cleanup;
// Overload for objects
export function observe<V, K extends Key>(
getter: () => { readonly [K in Key]: V },
fn: (value: V, key: K) => Cleanup | void,
): Cleanup;
/**
* Disables recursive checks for the current effect or computed signal.
* Useful for effects that intentionally update signals they depend on.
*
* @example
* ```ts
* effect(() => {
* recursive();
* setCount(getCount() + 1);
* })
* ```
*/
export function recursive(): void;
/**
* Creates a deep reactive value that tracks properties when they're accessed.
* Returns a proxy object that subscribes to changes on any nested property
* you read, and a function for mutating the original table.
*
* @example
* ```luau
* local users, setUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* setUsers(function(state)
* state.user.name = "Jane"
* state.user.surname = "Smith"
* end)
* ```
*
* @param initialValue The value to make deeply reactive.
* @return A proxy table that tracks reads to nested properties.
* @return A function for updating the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function reactive<T extends object>(
initialValue: T,
): LuaTuple<[value: T, setter: (update: T | ((initialValue: T) => void)) => T]>;
export function getActiveSub(): ReactiveNode | undefined;
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
export function startBatch(): void;
export function endBatch(): void;

View file

@ -1,27 +1,957 @@
local atom = require(script.atom) local system = require("@self/system")
local computed = require(script.computed)
local effect = require(script.effect)
local mapped = require(script.mapped)
local observe = require(script.observe)
local store = require(script.store)
local subscribe = require(script.subscribe)
local types = require(script.types)
export type Atom<State> = types.Atom<State> export type ReactiveFlags = system.ReactiveFlags
export type Selector<State> = types.Selector<State> export type ReactiveNode = system.ReactiveNode
export type Molecule<State> = types.Selector<State> export type Link = system.Link
export type SyncPayload = types.SyncPayload
export type Atom<T = any> = (...((T) -> T) | T) -> T
type Target = { [any]: any }
type Proxy = {
__target: Target,
__signals: { [any]: () -> any },
}
type EffectScopeNode = ReactiveNode & {
cleanups: { () -> () },
}
type EffectNode = ReactiveNode & {
fn: () -> ...(() -> ())?,
cleanups: { () -> () },
}
type ComputedNode<T = any> = ReactiveNode & {
value: T?,
getter: (previousValue: T?) -> T,
}
type SignalNode<T = any> = ReactiveNode & {
currentValue: T,
pendingValue: T,
}
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local RECURSED_CHECK = 0b0000100
local DIRTY = 0b0010000
local PENDING = 0b0100000
local PEEKING = 0b1000000
local function inlineTest(): string
return debug.info(1, "n")
end
local flags = {
--[[
Enforces synchronous, non-yielding behavior in signals and effects.
Also enables state validation in Charm Sync to catch sync errors early.
Enabled if the optimization level is below 2, which is true in studio.
]]
strict = inlineTest() == "inlineTest",
--[[
Enforces immutability of tables by deep-freezing table values. Enabled
if the optimization level is below 2, which is true in studio.
]]
frozen = inlineTest() == "inlineTest",
--[[
Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be
disabled to revert to the old behavior. Defaults to `true`.
]]
trackInnerEffects = true,
}
local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link?
local propagate: (link: Link) -> ()
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
local shallowPropagate: (link: Link) -> ()
local cycle = 0
local batchDepth = 0
local notifyIndex = 0
local queued: { EffectNode? } = {}
local queuedLength = 0
local activeSub: ReactiveNode?
local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results...
if not flags.strict then
return fn
end
local function resultHandler(thread: thread, ok: boolean, ...)
if not ok then
error(debug.traceback(thread, tostring((...))))
elseif coroutine.status(thread) ~= "dead" then
error(debug.traceback(thread, "Yielding is not allowed in strict mode"))
end
return ...
end
return function(...)
local thread = coroutine.create(fn)
return resultHandler(thread, coroutine.resume(thread, ...))
end
end
local function deepFreeze(object: any)
if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then
table.freeze(object)
for _, value in object do
deepFreeze(value)
end
end
end
local function getActiveSub(): ReactiveNode?
return activeSub
end
local function setActiveSub(sub: ReactiveNode?)
local prevSub = activeSub
activeSub = sub
return prevSub
end
local function purgeDeps(sub: ReactiveNode)
local depsTail = sub.depsTail
local dep = if depsTail then depsTail.nextDep else sub.deps
while dep do
dep = unlink(dep, sub)
end
end
local function runCleanups(effect: EffectScopeNode)
local cleanups = effect.cleanups
for key, cleanup in cleanups do
local prevSub = setActiveSub(nil)
local success, result: any = pcall(cleanup)
activeSub = prevSub
cleanups[key] = nil
if not success then
warn("An error occurred during effect cleanup:", result)
end
end
end
local function run(e: EffectNode): ()
local flags = e.flags
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then
runCleanups(e)
cycle += 1
e.depsTail = nil
e.flags = bit32.bor(WATCHING, RECURSED_CHECK)
local prevSub = setActiveSub(e)
local success, result = pcall(e.fn)
activeSub = prevSub
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(e)
if not success then
error(result)
elseif result then
table.insert(e.cleanups, noYield(result))
end
else
e.flags = WATCHING
end
end
local function flush(): ()
while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: EffectNode
queued[notifyIndex] = nil
notifyIndex += 1
run(effect)
end
notifyIndex = 0
queuedLength = 0
end
local function startBatch()
batchDepth += 1
end
local function endBatch()
batchDepth -= 1
if batchDepth == 0 then
flush()
end
end
local function updateComputed(c: ComputedNode): boolean
cycle += 1
c.depsTail = nil
c.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(c)
local oldValue = c.value
local success, result = pcall(c.getter, oldValue)
activeSub = prevSub
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(c)
if not success then
error(result)
end
if oldValue ~= result then
c.value = result
return true
else
return false
end
end
local function updateSignal(s: SignalNode): boolean
s.flags = MUTABLE
local pendingValue = s.pendingValue
if s.currentValue ~= pendingValue then
s.currentValue = pendingValue
return true
else
return false
end
end
local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags)
sub.flags = bit32.band(flags, bit32.bnot(PENDING))
end
local function computedOper<T>(this: ComputedNode<T>): () -> T
local function computedGetter(): T
local flags = this.flags
if
bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags)))
then
if updateComputed(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif flags == NONE then
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
error(result)
end
end
if activeSub then
link(this, activeSub, cycle)
end
return this.value :: T
end
return computedGetter
end
local function signalOper<T>(
this: SignalNode<T>,
equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T)
local function signalSetter(update: ((T) -> T) | T): T
local value: T
if type(update) == "function" then
local prevSub = setActiveSub(nil)
local success, result = pcall(noYield(update), this.pendingValue)
activeSub = prevSub
if not success then
error(result)
end
value = result
else
value = update
end
if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then
if flags.frozen then
deepFreeze(value)
end
this.pendingValue = value
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
end
return value
end
return this.currentValue
end
local function signalGetter(): T
if bit32.btest(this.flags, DIRTY) then
if updateSignal(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
end
local sub = activeSub
while sub do
if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then
link(this, sub, cycle)
break
end
local subs = sub.subs
sub = if subs then subs.sub else nil
end
return this.currentValue
end
return signalGetter, signalSetter
end
local function effectOper(this: EffectScopeNode): () -> ()
return function()
this.depsTail = nil
this.flags = NONE
purgeDeps(this)
local sub = this.subs
if sub then
unlink(sub, this)
end
runCleanups(this)
end
end
--[[
Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter.
@param initialValue The initial value of the signal.
@param equals A comparator function to determine if the signal's value has changed.
@return A function to get the current value of the signal.
@return A function to set a new value and notify subscribers.
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
]]
local function signal<T>(
initialValue: T,
equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T)
if flags.frozen then
deepFreeze(initialValue)
end
return signalOper({
currentValue = initialValue,
pendingValue = initialValue,
flags = MUTABLE,
}, equals)
end
--[[
Creates a reactive atom that acts as both a signal getter and setter.
Calling the atom with an argument sets its value, while calling it with no
arguments returns its current value.
@param initialValue The initial value of the atom. If empty, the atom's value will be `nil`.
@param equals A comparator function to determine if the atom's value has changed.
@see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
]]
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom<T>
local getter, setter = signal(initialValue, equals)
local function atomOper(...)
return if select("#", ...) == 0 then getter() else setter((...))
end
return atomOper
end
--[[
Creates a new read-only signal that is computed based on the values of
other signals. The computed signal's value is automatically updated when a
change is detected in any of its dependencies.
@param getter A function that produces the next value.
@see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
]]
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedOper({
flags = NONE,
getter = noYield(getter),
})
end
--[[
Creates an effect that runs a callback in response to signal state changes.
An effect tracks which signals are accessed during its execution, and runs
the callback when those signals change.
The effect callback may return a cleanup function, which gets called once,
either before the effect re-runs or when it is disposed.
If the effect is called within another effect, it will be disposed when the
parent effect re-runs.
@param fn The function to run when dependencies change.
@return A function for disposing the effect.
@see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
]]
local function effect(fn: () -> ...(() -> ())?): () -> ()
local e: EffectNode = {
fn = noYield(fn),
flags = bit32.bor(WATCHING, RECURSED_CHECK),
cleanups = {},
}
local prevSub = setActiveSub(e)
if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then
link(e, prevSub, 0)
end
local success, result = pcall(e.fn)
activeSub = prevSub
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
if not success then
error(result)
elseif result then
table.insert(e.cleanups, noYield(result))
end
return effectOper(e)
end
--[[
Creates an effect scope that can capture reactive effects and computed
signals created within it so that they can be disposed together.
@param fn A function that may create reactive effects.
@param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
@return A function for disposing all effects created within the scope.
@see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
]]
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
local e: EffectScopeNode = {
flags = NONE,
cleanups = {},
}
local prevSub = setActiveSub(e)
if prevSub and not detached then
link(e, prevSub, 0)
end
local success, result = pcall(noYield(fn))
activeSub = prevSub
if not success then
error(result)
elseif result then
table.insert(e.cleanups, noYield(result))
end
return effectOper(e)
end
--[[
Allows you to manually trigger updates for downstream dependencies when
you've directly mutated a signal's value without using the signal setter.
@param fn The dependency or a function that calls multiple dependencies.
@see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
]]
local function trigger(fn: () -> ())
local sub: ReactiveNode = {
flags = WATCHING,
}
local prevSub = setActiveSub(sub)
local success, result: any = pcall(noYield(fn))
activeSub = prevSub
while sub.deps do
local link = sub.deps :: Link
local dep = link.dep
unlink(link, sub)
if dep.subs then
propagate(dep.subs)
shallowPropagate(dep.subs)
end
end
if batchDepth == 0 then
flush()
end
if not success then
error(result)
end
end
--[[
Binds the cleanup function to the current active effect or effect scope. If
there is no active effect, a warning is logged, unless `failSilently` is
set to `true`.
@param fn The cleanup function to register.
@param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
@see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
]]
local function onCleanup(fn: () -> (), failSilently: boolean?)
if activeSub and (activeSub :: EffectNode).cleanups then
table.insert((activeSub :: EffectNode).cleanups, noYield(fn))
elseif not failSilently then
warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2))
end
end
--[[
Runs the function without subscribing to signal updates or capturing inner
effects in the parent effect or scope.
For an alternative that still cleans up inner effects, use `peek` instead.
@param fn A function that may read signals or create effects.
@param ...args Arguments to pass to the function.
@return The return value of the function.
@see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
]]
local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
if activeSub then
local prevSub = setActiveSub(nil)
local results: { any } = { pcall(noYield(fn), ...) }
activeSub = prevSub
if not results[1] then
error(results[2])
end
return unpack(results, 2)
else
return fn(...)
end
end
--[[
Runs the function without subscribing to signal updates, but still captures
inner effects so that they dispose with the parent effect.
To avoid capturing inner effects, use `untracked` instead.
@param fn A function that may read signals.
@param ...args Arguments to pass to the function.
@return The return value of the function.
@see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
]]
local function peek<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
if
activeSub
and bit32.band(activeSub.flags, PEEKING) == 0
and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE))
then
activeSub.flags = bit32.bor(activeSub.flags, PEEKING)
local results: { any } = { pcall(noYield(fn), ...) }
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING))
if not results[1] then
error(results[2])
end
return unpack(results, 2)
else
return fn(...)
end
end
--[[
Combines multiple updates into a single "commit" that runs effects and
computed signals after the provided callback finishes running.
@param fn A function that performs multiple updates.
@param ...args Arguments to pass to the function.
@return The return value of the function.
@see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
]]
local function batch<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
startBatch()
local results: { any } = { pcall(noYield(fn), ...) }
endBatch()
if not results[1] then
error(results[2])
end
return unpack(results, 2)
end
--[[
Converts a non-signal into a computed signal. If the function is already a
signal, it is returned as-is. Useful for memoizing a getter function
without creating unnecessary nested signals.
@param fn A function that returns the value to track.
@return A computed signal that tracks the value returned by the getter.
]]
local function toSignal<T>(fn: () -> T): () -> T
local name = debug.info(fn, "n")
return if name == "signalGetter" or name == "computedGetter" or name == "atomOper" then fn else computed(fn)
end
--[[
Creates an effect that runs the callback one immediately, and then again
when the value returned by the getter changes. The callback receives the new
value and the previous value as arguments.
Effects created within the callback are cleaned up when the listener runs
or the listener is disposed.
@param getter A function that returns the value to watch.
@param fn The function to run when the value changes.
@return A function for disposing the effect.
@see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
]]
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> ()
local tracked = toSignal(getter)
local current: T
return effect(function()
local previous = current
current = tracked()
peek(fn, current, previous)
end)
end
--[[
Creates an effect that only runs the callback when the value returned by the
getter changes. The callback receives the new value and the previous value
as arguments.
Effects created within the callback are cleaned up when the listener runs
or the listener is disposed.
@param getter A function that returns the value to watch.
@param fn The function to run when the value changes.
@return A function for disposing the effect.
@see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
]]
local function subscribe<T>(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> ()
local tracked = toSignal(getter)
local current: T
local firstRun = true
return effect(function()
local previous = current
current = tracked()
if firstRun then
firstRun = false
else
peek(fn, current, previous)
end
end)
end
--[[
Disables recursive checks for the current effect or computed signal. Useful
for effects that intentionally update signals they depend on.
@example
```luau
effect(function()
recursive()
setCount(getCount() + 1)
end)
```
]]
local function recursive()
if activeSub then
activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK))
else
warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2))
end
end
--[[
Calls the callback function for each unique key in the table returned by the
getter. The callback is called once for each key, and again when a new key
is added.
The callback may return a cleanup function, which will be called when the
key is removed or the `observe()` effect is disposed. Effects created
during the callback will also be bound to the lifetime of the key.
@param getter A function that returns the table to observe.
@param fn A function that is called for each key in the table.
@return A function for disposing the observer.
@see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
]]
local function observe<K, V>(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> ()
local innerScopes: { [K]: () -> () } = {}
local disposed = false
local function updateScopes(data: { [K]: V })
for key, dispose in innerScopes do
if data[key] == nil then
innerScopes[key] = nil
dispose()
if disposed then
return
end
end
end
for key, value in data do
if innerScopes[key] == nil then
local disposeScope = effectScope(function()
return fn(value, key)
end, true)
if disposed then
return disposeScope()
end
innerScopes[key] = disposeScope
end
end
end
return effectScope(function()
effect(function()
-- Allow recursive updates in case the callback creates or removes
-- items in the observed table.
recursive()
-- Batch so that side effects run after all scopes are created.
-- This ensures that a scope creating or removing items doesn't
-- interfere with the current update cycle.
batch(updateScopes, getter())
end)
return function()
if disposed then
return
end
disposed = true
for key, dispose in innerScopes do
innerScopes[key] = nil
dispose()
end
end
end)
end
--[[
Creates a new read-only signal that is computed by mapping over the entries
of an existing atom. If the key is omitted from the mapper's return value,
the original key is preserved.
@param getter Returns the table to map over.
@param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
@see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
]]
local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
local currentInput: { [KI]: VI } = {}
local keyCache: { [KI]: KO } = {}
local function map(value: VI, key: KI): (VO, KO)
local prevSub = setActiveSub(nil)
local success, valueOut, keyOut = pcall(mapper, value, key)
activeSub = prevSub
if not success then
error(`mapped() transform function failed: {valueOut}`)
end
return valueOut, if keyOut == nil then key else keyOut :: any
end
return computed(function(previousOutput)
local currentOutput: { [KO]: VO } = previousOutput or {}
local previousInput = currentInput
currentInput = getter()
for keyIn in previousInput do
if currentInput[keyIn] ~= nil then
continue
end
local prevKeyOut = keyCache[keyIn]
if prevKeyOut then
if currentOutput == previousOutput then
currentOutput = table.clone(currentOutput)
end
currentOutput[prevKeyOut] = nil
keyCache[keyIn] = nil
end
end
for keyIn, valueIn in currentInput do
if valueIn == previousInput[keyIn] then
continue
end
local valueOut, keyOut = map(valueIn, keyIn)
if currentOutput[keyOut] ~= valueOut then
if currentOutput == previousOutput then
currentOutput = table.clone(currentOutput)
end
currentOutput[keyOut] = valueOut
keyCache[keyIn] = keyOut
end
end
return currentOutput
end)
end
--[[
Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property
you read, and a function for mutating the original table.
@example
```luau
local users, setUsers = reactive({
user = { name = "John", surname = "Doe" },
})
effect(function()
print(users.user and `{users.user.name} {users.user.surname}`)
end)
setUsers(function(state)
state.user.name = "Jane"
state.user.surname = "Smith"
end)
```
@param initialValue The value to make deeply reactive.
@return A proxy table that tracks reads to nested properties.
@return A function for updating the original table.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function reactive<T>(initialValue: T & {}): (T, (update: (T) -> () | T?) -> ())
local track = signal(nil)
local function setValue(update: ((T) -> ()) | T?)
if type(update) == "function" then
update(initialValue)
elseif type(update) == "table" then
table.clear(initialValue)
for key, value in update do
(initialValue :: Target)[key] = value
end
end
trigger(track)
end
local proxyMeta = {
initialValue = initialValue,
track = track,
setValue = setValue,
}
local proxyMap = setmetatable({} :: { [Target]: any }, { __mode = "k" })
local function trackProperty(cache: { [any]: () -> any }, target: Target, key: any)
if not cache[key] then
cache[key] = untracked(computed, function()
track()
return target[key]
end)
end
cache[key]()
end
local function createProxy(target: Target): Proxy
if not proxyMap[target] then
proxyMap[target] = setmetatable({
__target = target,
__signals = setmetatable({}, { __mode = "v" }),
}, proxyMeta)
end
return proxyMap[target]
end
local function nextProxy(target: Target, index: any)
local nextKey, nextValue = next(target, index)
return nextKey, if type(nextValue) == "table" then createProxy(nextValue) else nextValue
end
function proxyMeta.__index(self: Proxy, key: any): any
if activeSub then
trackProperty(self.__signals, self.__target, key)
end
local value = self.__target[key]
return if type(value) == "table" then createProxy(value) else value
end
function proxyMeta.__newindex(self: Proxy, key: any, value: any)
if type(value) == "table" and getmetatable(value) == proxyMeta then
value = value.__target
end
if self.__target[key] ~= value then
self.__target[key] = value
trigger(track)
end
end
function proxyMeta.__iter(self: Proxy)
track()
return nextProxy, self.__target
end
function proxyMeta.__len(self: Proxy)
track()
return #self.__target
end
return createProxy(initialValue) :: any, setValue
end
local reactiveSystem = system.createReactiveSystem({
update = function(node)
if node.depsTail then
return updateComputed(node :: ComputedNode)
else
return updateSignal(node :: SignalNode)
end
end,
notify = function(effect)
local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
repeat
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
queued[insertIndex] = effect :: EffectNode
insertIndex += 1
effect = (effect.subs and effect.subs.sub) :: EffectNode
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
break
end
until false
queuedLength = insertIndex
insertIndex -= 1
while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left
firstInsertedIndex += 1
insertIndex -= 1
end
end,
unwatched = function(node)
if bit32.band(node.flags, MUTABLE) == 0 then
effectOper(node :: EffectScopeNode)()
elseif node.depsTail then
node.depsTail = nil
node.flags = bit32.bor(MUTABLE, DIRTY)
purgeDeps(node)
end
end,
})
link = reactiveSystem.link
unlink = reactiveSystem.unlink
propagate = reactiveSystem.propagate
checkDirty = reactiveSystem.checkDirty
shallowPropagate = reactiveSystem.shallowPropagate
return { return {
atom = atom, ReactiveFlags = system.ReactiveFlags,
flags = flags,
getActiveSub = getActiveSub,
setActiveSub = setActiveSub,
startBatch = startBatch,
endBatch = endBatch,
signal = signal,
computed = computed, computed = computed,
effect = effect, effect = effect,
mapped = mapped, effectScope = effectScope,
observe = observe, trigger = trigger,
atom = atom,
onCleanup = onCleanup,
untracked = untracked,
peek = peek,
batch = batch,
subscribe = subscribe, subscribe = subscribe,
batch = store.batch, listen = listen,
capture = store.capture, recursive = recursive,
isAtom = store.isAtom, observe = observe,
notify = store.notify, mapped = mapped :: (<KI, VI, KO, VO>(
peek = store.peek, getter: () -> { [KI]: VI },
mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
reactive = reactive,
} }

View file

@ -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

View file

@ -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

View file

@ -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,
}

View file

@ -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

View 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,
}

View file

@ -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

View file

@ -1,7 +1,8 @@
[package] [package]
name = "littensy/charm" name = "littensy/charm"
version = "0.10.0" version = "0.11.0-rc.2"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = ["node_modules", "src/__tests__", "src/index.d.ts", "package.json"]
[dependencies] [dependencies]

View file

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

View 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)

View file

@ -0,0 +1 @@
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").ReactRoblox)

View file

@ -1,5 +1,5 @@
{ {
"name": "VideCharm", "name": "react-charm",
"tree": { "tree": {
"$path": "src" "$path": "src"
} }

View file

@ -0,0 +1,30 @@
{
"name": "@rbxts/react-charm",
"version": "0.4.0-rc.2",
"description": "React bindings for Charm state management",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:*"
},
"peerDependencies": {
"@rbxts/react": "*"
}
}

View file

@ -1,9 +1,7 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
_G.__ROACT_17_MOCK_SCHEDULER__ = true _G.__ROACT_17_MOCK_SCHEDULER__ = true
local React = require(ReplicatedStorage.DevPackages.React) local React = require("../../../React")
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox) local ReactRoblox = require("../../../ReactRoblox")
type RenderedHook<Props, Result> = { type RenderedHook<Props, Result> = {
result: Result, result: Result,
@ -15,11 +13,11 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder")) local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
local hook = {} local hook = {}
local function Component(props) local function Component(props: Props)
hook.result = callback(props) hook.result = callback(props)
end end
function hook.rerender(newProps) function hook.rerender(newProps: Props?)
props = newProps or props props = newProps or props
ReactRoblox.act(function() ReactRoblox.act(function()
root:render(React.createElement(Component, props)) root:render(React.createElement(Component, props))

View 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 {}

View 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.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 {}

View 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
View 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 };

View file

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

View file

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

View file

@ -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": "*"
}
}

View file

@ -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;

View file

@ -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,
}

View file

@ -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"

View file

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

View file

@ -0,0 +1 @@
return require(script.Parent.Parent.vide.src)

View file

@ -1,5 +1,5 @@
{ {
"name": "ReactCharm", "name": "vide-charm",
"tree": { "tree": {
"$path": "src" "$path": "src"
} }

View file

@ -0,0 +1,30 @@
{
"name": "@rbxts/vide-charm",
"version": "0.4.0-rc.2",
"description": "Vide bindings for Charm state management",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:*"
},
"peerDependencies": {
"@rbxts/vide": "*"
}
}

View file

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

View 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
View 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 };

View file

@ -0,0 +1,25 @@
local Charm = require("./Charm")
local Vide = require("./Vide")
--[[
Returns a reactive source that subscribes to the current value of the given
signal or getter function.
@param getter The getter function to subscribe to.
@return The reactive source.
]]
local function useSignalState<State>(getter: () -> State): () -> State
local state = Vide.source(getter())
Vide.cleanup(Charm.effect(function()
-- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(state :: (State) -> State, getter())
end))
return state
end
return {
useSignalState = useSignalState,
useAtom = useSignalState,
}

View file

@ -0,0 +1,17 @@
[package]
name = "littensy/vide-charm"
version = "0.4.0-rc.2"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"node_modules",
"src/__tests__",
"src/index.d.ts",
"package.json",
"Vide.luau",
"Charm.luau",
]
[dependencies]
Charm = "littensy/charm@0.11.0-rc.2"
Vide = "centau/vide@^0.3"

View file

@ -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