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
100 changed files with 2869 additions and 4260 deletions

View file

@ -14,19 +14,11 @@ jobs:
run_install: true
- uses: CompeyDev/setup-rokit@v0.1.2
- name: Setup Lute
run: lute setup --with-luaurc
- name: Check code quality
run: lute run check
- name: Run ESLint and Prettier
run: |
pnpm eslint packages
pnpm prettier --check packages
run: sh bin/check.sh
- name: Run tests
run: lute run test
run: lune run test/spec.server.luau
- name: Build models
run: lute run build
run: sh bin/build.sh

View file

@ -11,11 +11,8 @@ jobs:
- uses: actions/checkout@v4
- uses: CompeyDev/setup-rokit@v0.1.2
- name: Setup Lute
run: lute setup --with-luaurc
- name: Build models
run: lute run build
run: sh bin/build.sh
- name: Upload release assets
uses: AButler/upload-release-assets@v3.0

2
.gitignore vendored
View file

@ -2,7 +2,7 @@
/.pesde
/build
/packages/**/wally.lock
/packages/**/LICENSE*
/packages/**/LICENSE.md
/packages/**/README.md
/sourcemap.json
node_modules

4
.gitmodules vendored
View file

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

View file

@ -1,7 +1,6 @@
{
"languageMode": "strict",
"aliases": {
"lute": "~/.lute/typedefs/1.0.0/lute",
"std": "~/.lute/typedefs/1.0.0/std"
"lint": {
"LocalShadow": false
}
}

View file

@ -1,6 +0,0 @@
local fs = require("@std/fs")
local process = require("@lute/process")
fs.createDirectory("build")
process.run({ "rojo", "build", "--output=build/charm.rbxm", "build.project.json" }, { stdio = "inherit" })

View file

@ -1,33 +0,0 @@
local fs = require("@std/fs")
local net = require("@std/net")
local process = require("@lute/process")
process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })
fs.writeStringToFile(
"roblox.d.luau",
net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
)
local analyze = process.run({
"luau-lsp",
"analyze",
"--sourcemap=sourcemap.json",
"--ignore=**/node_modules/**",
"--base-luaurc=.luaurc",
"--definitions=roblox.d.luau",
"--flag:LuauSolverV2=true",
"packages",
"tests",
"benches",
}, { stdio = "inherit" })
fs.remove("roblox.d.luau")
local stylua = process.run({ "stylua", "--check", "packages", "tests", "benches" }, { stdio = "inherit" })
local eslint = process.run({ "pnpm", "eslint", "packages" }, { stdio = "inherit" })
if not (analyze.ok and stylua.ok and eslint.ok) then
process.exit(1)
end

View file

@ -1 +0,0 @@
(require)("../tests/run.server")

1
.npmrc Normal file
View file

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

21
LICENSE
View file

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright 2024-present Littensy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

7
LICENSE.md Normal file
View file

@ -0,0 +1,7 @@
Copyright 2024 Littensy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

781
README.md

File diff suppressed because it is too large Load diff

View file

@ -20,8 +20,8 @@ local function run(name: string, test: () -> ())
table.insert(results, `{name}: \t{string.format("%.2f", elapsed / iter * 1000 * 1000)} us/iter`)
for name, duration in times do
table.insert(results, `{name}: \t{string.format("%.2f", duration / iter * 1000 * 1000)} us/iter`)
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, " | "))

View file

@ -3,56 +3,55 @@ local bench = require("./bench")
local computed = Charm.computed
local mapped = Charm.mapped
local setActiveSub = Charm.setActiveSub
local signal = Charm.signal
type Key = number
type Value = number
type Entry = { number }
local count = 512
local initialState: { [Key]: Value } = table.create(count, 0)
local initialState: { Entry } = table.create(count)
local function expensiveOperation()
for _ = 1, 100 do
continue
end
for i = 1, count do
initialState[i] = table.create(64, i)
end
local function simpleMapped(
source: () -> { [Key]: Value },
mapper: (Value, Key) -> (Value, Key?)
): () -> { [Key]: Value }
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 keyIn, valueIn in state do
local valueOut, keyOut = mapper(valueIn, keyIn)
nextState[if keyOut == nil then keyIn else keyOut] = valueOut
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 nextState
return state
end)
end
local function updateRandomly(updateChance: number): ({ [Key]: Value }) -> { [Key]: Value }
local function updatePercentage(percent: number): ({ Entry }) -> { Entry }
return function(state)
local nextState = table.clone(state)
for key in state do
if math.random() < updateChance then
nextState[key] += 1
for index, value in state do
if math.random() < percent then
nextState[index] = table.clone(value)
end
end
return nextState
end
end
for _, updateChance in { 0.1, 0.5, 1 } do
local function mapper(value: Value, key: Key): (Value, Key?)
expensiveOperation()
return value + 1, key + 1
end
bench.run(`mapped {updateChance}`, function()
for _, percent in { 0.1, 0.5, 1 } do
bench.run(`mapped {percent}`, function()
local src, setSrc = signal(initialState)
local out = mapped(src, mapper)
@ -60,14 +59,14 @@ for _, updateChance in { 0.1, 0.5, 1 } do
out()
bench.profileend()
setSrc(updateRandomly(updateChance))
setSrc(updatePercentage(percent))
bench.profilebegin("update")
out()
bench.profileend()
end)
bench.run(`control {updateChance}`, function()
bench.run(`control {percent}`, function()
local src, setSrc = signal(initialState)
local out = simpleMapped(src, mapper)
@ -75,7 +74,7 @@ for _, updateChance in { 0.1, 0.5, 1 } do
out()
bench.profileend()
setSrc(updateRandomly(updateChance))
setSrc(updatePercentage(percent))
bench.profilebegin("update")
out()

View file

@ -1,188 +0,0 @@
local Charm = require("../packages/charm/src")
local bench = require("./bench")
type Atom<T> = Charm.Atom<T>
local signal = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local function busy(): number
local a = 0
for i = 1, 100 do
a += 1
end
return a
end
do
local head = signal(0)
local computed1 = computed(function()
return head()
end)
local computed2 = computed(function()
computed1()
return 0
end)
local computed3 = computed(function()
busy() -- heavy computation
return computed2() + 1
end)
local computed4 = computed(function()
return computed3() + 2
end)
local computed5 = computed(function()
return computed4() + 3
end)
effect(function()
computed5()
busy() -- heavy side effect
end)
bench.run("avoidablePropagation", function()
head(1)
assert(computed5() == 6)
for i = 0, 999 do
head(i)
end
assert(computed5() == 6)
end)
end
do
local head = signal(0)
local double = computed(function()
return head() * 2
end)
local inverse = computed(function()
return -head()
end)
local current = computed(function()
local result = 0
for i = 0, 19 do
result += if head() % 2 == 1 then double() else inverse()
end
return result
end)
local callCounter = 0
effect(function()
current()
callCounter += 1
end)
bench.run("unstable", function()
head(1)
assert(current() == 40)
callCounter = 0
for i = 0, 99 do
head(i)
end
assert(callCounter == 100)
end)
end
do
local head = signal(0)
local double = computed(function()
return head() * 2
end)
local inverse = computed(function()
return -head()
end)
local current = computed(function()
local result = 0
for i = 0, 19 do
result += if head() % 2 == 1 and i % 2 == 1 then double() else inverse()
end
return result
end)
local callCounter = 0
effect(function()
current()
callCounter += 1
end)
bench.run("unstable 50%", function()
head(1)
assert(current() == 10)
callCounter = 0
for i = 0, 99 do
head(i)
end
assert(callCounter == 100)
end)
end
do
local head = signal(0)
local last: () -> number = head
local callCounter = 0
for i = 0, 49 do
local current = computed(function()
return head() + i
end)
local current2 = computed(function()
return current() + 1
end)
effect(function()
current2()
callCounter += 1
end)
last = current2
end
bench.run("broadPropagation", function()
head(1)
callCounter = 0
for i = 0, 49 do
head(i)
assert(last() == i + 50)
end
assert(callCounter == 50 * 50)
end)
end
do
local heads = {}
for index = 0, 99 do
heads[index] = signal(0)
end
local mux = computed(function()
local result = {}
for index = 0, 99 do
result[index] = heads[index]()
end
return result
end)
local splited = {}
for index = 0, 99 do
local x = computed(function()
return mux()[index]
end)
splited[index] = computed(function()
return x() + 1
end)
end
for _, x in splited do
effect(function()
x()
end)
end
bench.run("mux", function()
for i = 0, 9 do
heads[i](i)
assert(splited[i]() == i + 1)
end
for i = 0, 9 do
heads[i](i * 2)
assert(splited[i]() == i * 2 + 1)
end
end)
end

View file

@ -1,9 +1,8 @@
local Charm = require("../packages/charm/src")
local DeepCharm = require("../packages/deep-charm/src")
local bench = require("./bench")
local effect = Charm.effect
local reactive = DeepCharm.reactive
local reactive = Charm.reactive
do
local value = reactive({ a = 1 })

4
bin/build.sh Normal file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
mkdir build
rojo build build.project.json -o build/charm.rbxm

19
bin/check.sh Normal file
View file

@ -0,0 +1,19 @@
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
rojo sourcemap -o sourcemap.json
luau-lsp analyze \
--defs=bin/roblox.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \
--flag:LuauInstantiateInSubtyping=true \
--sourcemap=sourcemap.json \
--ignore="**/node_modules/**" \
packages test benches
selene packages test benches
stylua --check packages test benches
pnpm eslint packages
rm bin/roblox.d.luau

View file

@ -4,7 +4,6 @@
"$className": "Folder",
"Charm": { "$path": "packages/charm/src" },
"CharmSync": { "$path": "packages/charm-sync/src" },
"DeepCharm": { "$path": "packages/deep-charm/src" },
"ReactCharm": { "$path": "packages/react-charm/src" },
"VideCharm": { "$path": "packages/vide-charm/src" }
}

View file

@ -19,25 +19,16 @@
},
"charm": {
"$className": "Folder",
"src": { "$path": "packages/charm/src" },
"test": { "$path": "packages/charm/test" }
},
"deep-charm": {
"$className": "Folder",
"src": { "$path": "packages/deep-charm/src" },
"test": { "$path": "packages/deep-charm/test" },
"Charm": { "$path": "packages/deep-charm/Charm.luau" }
"src": { "$path": "packages/charm/src" }
},
"charm-sync": {
"$className": "Folder",
"src": { "$path": "packages/charm-sync/src" },
"test": { "$path": "packages/charm-sync/test" },
"Charm": { "$path": "packages/charm-sync/Charm.luau" }
},
"react-charm": {
"$className": "Folder",
"src": { "$path": "packages/react-charm/src" },
"test": { "$path": "packages/react-charm/test" },
"React": { "$path": "packages/react-charm/React.luau" },
"ReactRoblox": { "$path": "packages/react-charm/ReactRoblox.luau" },
"Charm": { "$path": "packages/react-charm/Charm.luau" }
@ -45,7 +36,6 @@
"vide-charm": {
"$className": "Folder",
"src": { "$path": "packages/vide-charm/src" },
"test": { "$path": "packages/vide-charm/test" },
"Vide": { "$path": "packages/vide-charm/Vide.luau" },
"Charm": { "$path": "packages/vide-charm/Charm.luau" }
}
@ -53,8 +43,8 @@
"@rbxts-js": {
"$path": "node_modules/@rbxts-js"
},
"tests": {
"$path": "tests"
"test": {
"$path": "test"
}
}
}

View file

@ -1,20 +0,0 @@
const eslint = require("@eslint/js");
const { defineConfig, globalIgnores } = require("eslint/config");
const prettierRecommended = require("eslint-plugin-prettier/recommended");
const roblox = require("eslint-plugin-roblox-ts");
const tseslint = require("typescript-eslint");
module.exports = defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
roblox.configs.tsRecommendedCompat,
roblox.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"roblox-ts/no-any": "off",
},
},
globalIgnores(["out/**", "eslint.config.js"]),
prettierRecommended,
);

20
eslint.config.ts Normal file
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,29 +1,30 @@
{
"private": true,
"scripts": {
"test": "lute run test",
"build": "lute run build",
"check": "lute run check",
"test": "lune run test/spec.server.luau",
"build": "sh bin/build.sh",
"check": "sh bin/check.sh",
"publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
},
"packageManager": "pnpm@12.8.1",
"packageManager": "pnpm@10.22.0",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.36.0",
"@rbxts/compiler-types": "3.0.0-types.0",
"@rbxts/react": "17.3.7-ts.1",
"@rbxts/react-roblox": "17.3.7-ts.1",
"@rbxts/types": "^1.0.913",
"@rbxts/vide": "^0.6.1",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"eslint": "^9.39.4",
"@rbxts/react": "17.3.5-alpha.1",
"@rbxts/react-roblox": "17.3.5-alpha.1",
"@rbxts/types": "^1.0.883",
"@rbxts/vide": "^0.5.7",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-roblox-ts": "^1.4.0",
"prettier": "^3.8.1",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-roblox-ts": "^1.2.1",
"jiti": "^2.6.0",
"prettier": "^3.6.2",
"roblox-ts": "^3.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.58.0"
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1"
}
}

View file

@ -4,6 +4,6 @@ end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T> = Charm.Atom<T>
export type Atom<T = any> = Charm.Atom<T>
return Charm

View file

@ -1,6 +1,6 @@
{
"name": "@rbxts/charm-sync",
"version": "0.4.1",
"version": "0.4.0-rc.2",
"description": "Simple server-to-client sync for Charm",
"license": "MIT",
"main": "src/init.luau",
@ -11,17 +11,17 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
"@rbxts/charm": "workspace:*"
}
}

View file

@ -1,9 +1,11 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local test = require("../../../tests/test")
local Charm = require("../../Charm")
local client = require("../client")
local suite = require("../../../../test/suite")
test.afterEach(function()
local test = suite.test
local afterEach = suite.afterEach
afterEach(function()
client.removeAllSignals()
end)
@ -39,7 +41,7 @@ test("should merge patch", function()
end)
test("should merge patch (reactive)", function()
local a = DeepCharm.reactive({ b = 1, c = 2 })
local a = Charm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()

View file

@ -1,11 +1,13 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local client = require("../src/client")
local config = require("../src/config")
local server = require("../src/server")
local test = require("../../../tests/test")
local Charm = require("../../Charm")
local client = require("../client")
local config = require("../config")
local server = require("../server")
local suite = require("../../../../test/suite")
test.afterEach(function()
local afterEach = suite.afterEach
local test = suite.test
afterEach(function()
server.reset()
client.removeAllSignals()
config.preserveHistory = false
@ -14,9 +16,9 @@ end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
@ -51,9 +53,9 @@ end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = DeepCharm.reactive({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = DeepCharm.reactive({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
config.preserveHistory = true

View file

@ -1,6 +1,9 @@
local config = require("../src/config")
local patch = require("../src/patch")
local test = require("../../../tests/test")
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
@ -20,7 +23,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any }
end
end
test.afterEach(function()
afterEach(function()
config.fixArrays = true
config.validatePatches = true
end)
@ -124,10 +127,4 @@ test("patches mutable state", function()
assert(targetState.qaz == "qux", "should add the new value")
end)
test("removes table from mutable state", function()
local targetState = { foo = { bar = "baz" } }
patch.applyMutable(targetState, { foo = { __none = "__none" } })
assert(targetState.foo == nil, "should remove the table")
end)
return {}

View file

@ -1,12 +1,15 @@
local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local config = require("../src/config")
local patch = require("../src/patch")
local server = require("../src/server")
local test = require("../../../tests/test")
local types = require("../src/types")
local Charm = require("../../Charm")
local config = require("../config")
local patch = require("../patch")
local server = require("../server")
local suite = require("../../../../test/suite")
test.afterEach(function()
local afterEach = suite.afterEach
local beforeEach = suite.beforeEach
local describe = suite.describe
local test = suite.test
afterEach(function()
server.reset()
end)
@ -15,7 +18,7 @@ test("should replicate state changes", function()
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
@ -42,7 +45,7 @@ test("should replicate partial state patches", function()
local client = {}
local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?)
local target: unknown, payloads: { types.SyncPayload }
local target, payloads
server.addSignalsToClient(client, { a = a, b = b })
server.connect(function(...)
@ -68,8 +71,8 @@ end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target: unknown, payloads: { types.SyncPayload }
local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
@ -79,47 +82,25 @@ test("should replicate partial state patches (reactive)", function()
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
assert(payloads[1].data.a.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.y = 1
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.y == 1, "Expected patch payload to contain value 'a.x'")
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")
a.z = 1
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'")
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
end)
test("should handle nested proxies", function()
local client = {}
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
server.connect(function(_, newPayloads)
payloads = newPayloads
end)
server.flush()
payloads = nil
server.flush()
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
local payloadsA, payloadsB
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
@ -148,7 +129,7 @@ test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
local target, payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
@ -173,7 +154,7 @@ test("should only replicate to synced clients", function()
local clientB = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
local payloadsA, payloadsB
server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b })
@ -215,7 +196,7 @@ end)
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads: { types.SyncPayload }
local payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
@ -229,32 +210,12 @@ test("should serialize state", function()
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
test.describe("historical", function()
test.beforeEach(function()
describe("historical", function()
beforeEach(function()
config.preserveHistory = true
end)
test.afterEach(function()
afterEach(function()
config.preserveHistory = false
end)
@ -263,7 +224,7 @@ test.describe("historical", function()
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
@ -290,10 +251,10 @@ test.describe("historical", function()
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 })
local target: unknown, payloads: { types.SyncPayload }
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(...)
@ -322,7 +283,7 @@ test.describe("historical", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
local payloadsA, payloadsB
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
@ -358,7 +319,7 @@ test.describe("historical", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloads: { types.SyncPayload }
local payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...)
@ -383,74 +344,6 @@ test.describe("historical", function()
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
test("should maintain patch list order for stale update", function()
local a = Charm.atom({ x = 1 })
local b = Charm.atom({ y = 1 })
local payloads
server.connect(function(_client, _payloads)
payloads = _payloads
end)
server.addSignalsToClient("client", { a = a, b = b })
server.flush()
payloads = nil
a({ x = 1 }) -- new table, same contents: diff is nil
b({ y = 2 })
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "patch", "Expected payload to be patch")
assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'")
end)
end)
test("addSignalsToClient should ignore tracking context", function()
local counter = Charm.atom(0)
local payloads: { any }?
server.connect(function(_client, _payloads)
payloads = _payloads
end)
local dispose = Charm.effectScope(function()
server.addSignalsToClient("PLAYER_ID", { counter = counter })
end)
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'")
dispose()
payloads = nil
counter(1)
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'")
end)
return {}

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

@ -1,15 +1,16 @@
local Charm = require("../Charm")
local test = require("../../../tests/test")
local utils = require("../src/utils")
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<<number>>(Charm.signal(0)) } },
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
}
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
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")

View file

@ -3,18 +3,15 @@ local patch = require("./patch")
local types = require("./types")
type SyncPayload = types.SyncPayload
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
local trackedSignals: { [string]: (any) -> any } = {}
local trackedReactives: { [string]: ReactiveProxy } = {}
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
local target = reactive.__target
for key, value in source do
target[key] = value
end
Charm.trigger(reactive.__track)
end
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
local trackedSignals: { [string]: SignalSetter } = {}
local trackedReactives: { [string]: ReactiveSetter } = {}
--[[
Registers a map of setters to the state with the corresponding keys on the
@ -27,12 +24,12 @@ end
@param signals A map of setter functions to sync with the server.
]]
local function addSignals(signals: { [string]: ((any) -> any) | {} })
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] = (signal :: any) :: ReactiveProxy
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
end
end
end
@ -60,12 +57,9 @@ end
local function applyInitPayload(data: { [string]: any })
for key, value in data do
if trackedSignals[key] then
trackedSignals[key](value)
elseif trackedReactives[key] then
shallowMergeReactive(trackedReactives[key], value)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key]
if trackedSignal then
trackedSignal(value)
end
end
end
@ -80,10 +74,9 @@ local function applyPatchPayload(data: { [string]: any })
return patch.apply(value, diffs)
end)
elseif trackedReactive then
patch.applyMutable(trackedReactive.__target, diffs)
Charm.trigger(trackedReactive.__track)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
trackedReactive(function(value)
patch.applyMutable(value, diffs)
end)
end
end
end
@ -94,7 +87,7 @@ end
@param payloads The state updates received from the server.
]]
local function applyPatches(payloads: { SyncPayload })
local function patch(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
Charm.batch(applyInitPayload, payload.data)
@ -108,5 +101,5 @@ return {
addSignals = addSignals,
removeSignals = removeSignals,
removeAllSignals = removeAllSignals,
patch = applyPatches,
patch = patch,
}

View file

@ -9,22 +9,19 @@ return {
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple
times per frame, where each individual change is important. Defaults
to `false` for performance reasons.
to `false`.
]]
preserveHistory = false,
--[[
When `true`, Charm will modify sparse arrays to prevent data loss from
remote event limitations. When an unsafe array is detected, numeric
indices will be converted to string keys so that the array can be sent
safely and then converted back to an array on the receiving end.
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 emit a warning. Only checked in strict mode and if
tables will throw an error. Only checked in strict mode and if
`fixArrays` is enabled. Defaults to `true`.
]]
validatePatches = true,

View file

@ -78,17 +78,14 @@ export const config: {
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple
* times per frame, where each individual change is important. Defaults
* to `false` for performance reasons.
* to `false`.
*
* @server
*/
preserveHistory: boolean;
/**
* When `true`, Charm will modify sparse arrays to prevent data loss from
* remote event limitations. When an unsafe array is detected, numeric
* indices will be converted to string keys so that the array can be sent
* safely and then converted back to an array on the receiving end.
*
* 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.
*
@ -97,7 +94,7 @@ export const config: {
fixArrays: boolean;
/**
* When `true`, synced state containing unsafe sparse arrays or mixed
* tables will emit a warning. Only checked in strict mode and if
* tables will throw an error. Only checked in strict mode and if
* `fixArrays` is enabled. Defaults to `true`.
*
* @server
@ -245,7 +242,7 @@ export namespace patch {
* @param statePatch The patches to apply.
* @returns The new state with the patch applied.
*/
export function applyImmutable<State, FixArrays extends boolean = true>(
export function apply<State, FixArrays extends boolean = true>(
currentState: State,
statePatch: SyncPatch<State, FixArrays>,
): State;

View file

@ -39,7 +39,7 @@ end
long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied.
]]
local function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
local function stringifySparseArray(object: { any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
@ -65,7 +65,7 @@ local function diff(oldState: any, newState: any): any
return newState
end
local diffs = table.clone(newState)
local diffs: { [any]: any } = table.clone(newState)
for key, oldValue in oldState do
diffs[key] = diff(oldValue, newState[key])
@ -111,23 +111,23 @@ local function apply(oldState: any, diffs: any): any
return newState
end
local function applyMutable(targetState: any, diffs: any): any
if isNone(diffs) then
return nil
elseif type(targetState) ~= "table" or type(diffs) ~= "table" then
return diffs
end
local function applyMutable(targetState: any, sourceState: any): any
if type(targetState) == "table" and type(sourceState) == "table" then
local targetIsArray = targetState[1] ~= nil
local targetIsArray = targetState[1] ~= nil
for key, value in diffs do
if targetIsArray and type(key) == "string" then
key = tonumber(key) or key
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
targetState[key] = applyMutable(targetState[key], value)
end
return targetState
return targetState
elseif isNone(sourceState) then
return nil
else
return sourceState
end
end
return {

View file

@ -10,12 +10,14 @@ type SyncStatus = number
type ClientKeys = { [string]: SyncStatus }
type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
type TrackedState<T = any> = {
identifier: unknown,
target: any,
currentValue: T,
lastSyncedValue: T,
isReactive: boolean,
dispose: () -> (),
isReactive: boolean?,
}
type StateUpdate = {
@ -32,68 +34,74 @@ local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection?
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
--[[
Manually converts a reactive proxy to its raw representation to potentially
support custom implementations of reactive proxies.
]]
local function toRaw<T>(value: T): T
local proxy = value :: types.ReactiveProxy<T>
if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
proxy.__track()
return toRaw(proxy.__target)
end
return value
end
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
local function trackSignal<T>(key: string, getter: () -> T)
if trackedStates[key] then
-- Warn against name collisions with conflicting signals or proxies
if trackedStates[key].identifier ~= identifier 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
return
end
local trackedState: TrackedState<T>
local initialValue = getter()
local rawInitialValue = toRaw(initialValue)
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
local dispose = Charm.untracked(Charm.effect, function()
local newValue = getter()
local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue
if not trackedState then
return
end
local value = getter()
local trackedState = {
target = getter,
currentValue = value,
lastSyncedValue = value,
}
trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
-- When preserving history, we need to keep track of previous values
-- so we can create granular patches for each change.
if config.preserveHistory then
local prevSnapshot = snapshot
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
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
local meta = getmetatable(reactive)
local snapshot = utils.deepCopy(meta.initialValue)
local firstRun = true
local trackedState = {
target = reactive,
currentValue = meta.initialValue,
lastSyncedValue = snapshot,
isReactive = true,
}
trackedState.dispose = Charm.effect(function()
meta.track()
if not config.preserveHistory or firstRun then
firstRun = false
else
local oldSnapshot = snapshot
snapshot = utils.deepCopy(meta.initialValue)
table.insert(globalUpdates, {
key = key,
newValue = snapshot,
oldValue = prevSnapshot,
oldValue = oldSnapshot,
})
end
trackedState.currentValue = rawValue
trackedState.isReactive = isReactive
end)
trackedState = {
identifier = identifier,
currentValue = rawInitialValue,
lastSyncedValue = snapshot,
isReactive = initialValue ~= rawInitialValue,
dispose = dispose,
}
trackedStates[key] = trackedState
end
@ -146,7 +154,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
for index, update in globalUpdates do
local patchData = globalPatches[index]
if patchData == nil then
if not patchData then
continue
end
@ -154,24 +162,22 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
local status = clientKeys[key]
if status == LISTENING_FOR_CHANGES then
local payload: SyncPayload = {
table.insert(payloads, {
type = "patch",
data = { [key] = patchData },
}
table.insert(payloads, payload)
})
elseif status == PENDING_INITIAL_STATE then
local payload: SyncPayload = {
table.insert(payloads, {
type = "init",
data = { [key] = update.newValue },
}
table.insert(payloads, payload)
})
clientKeys[key] = LISTENING_FOR_CHANGES
end
end
-- If a pending signal was never updated, we still need to send its initial
-- state. This can happen if a signal was registered after its last change.
local initData = {}
local initData: { [string]: any } = {}
for key, status in clientKeys do
local trackedState = trackedStates[key]
@ -189,7 +195,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
return payloads
end
local function diffGlobalState(): { [string]: any }?
local function diffGlobalState(): any
local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {}
@ -206,7 +212,7 @@ local function diffGlobalState(): { [string]: any }?
return patch.diff(previousGlobalState, currentGlobalState)
end
local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }?
local clientKeys = clients[client]
if not clientKeys then
@ -222,7 +228,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
for key, status in clientKeys do
local trackedState = trackedStates[key]
if status == LISTENING_FOR_CHANGES and globalPatch then
if status == LISTENING_FOR_CHANGES then
patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue
@ -231,45 +237,16 @@ local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }
end
if next(initData) ~= nil then
local payload: SyncPayload = {
type = "init",
data = initData,
}
table.insert(payloads, payload)
table.insert(payloads, { type = "init", data = initData })
end
if next(patchData) ~= nil then
local payload: SyncPayload = {
type = "patch",
data = patchData,
}
table.insert(payloads, payload)
table.insert(payloads, { type = "patch", data = patchData })
end
return payloads
end
local function startInterval(callback: () -> ())
if not game then
return
end
if connection then
connection:Disconnect()
end
local timestamp = os.clock()
connection = game:GetService("RunService").Heartbeat:Connect(function()
if config.interval >= 0 and os.clock() >= timestamp + config.interval then
timestamp = os.clock()
debug.profilebegin("CharmSync")
callback()
debug.profileend()
end
end)
end
--[[
Subscribes a client to the given signals. When an update occurs, the client
will receive a partial state patch to merge into their local state. May be
@ -281,7 +258,7 @@ end
@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 })
local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any })
if not clients[client] then
clients[client] = {}
end
@ -290,11 +267,9 @@ local function addSignalsToClient(client: any, signals: { [string]: {} | () -> a
for key, signal in signals do
if type(signal) == "function" then
trackSignal(key, signal, signal)
trackSignal(key, signal)
else
trackSignal(key, function()
return signal
end, signal)
trackReactive(key, signal :: any)
end
if not clientKeys[key] then
@ -313,7 +288,6 @@ end
]]
local function removeClient(client: any)
clients[client] = nil
purgeUntracked()
end
--[[
@ -336,8 +310,6 @@ local function removeSignalsFromClient(client: any, ...: string)
if next(clientKeys) == nil then
removeClient(client)
else
purgeUntracked()
end
end
@ -352,11 +324,11 @@ local function flush()
end
local granular = config.preserveHistory
local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
for client in clients do
local payloads = if granular
then selectFromGlobalPatchList(client, globalPatch :: { any })
then selectFromGlobalPatchList(client, globalPatch)
else selectFromGlobalPatch(client, globalPatch)
if payloads and payloads[1] then
@ -368,6 +340,7 @@ local function flush()
end
end
purgeUntracked()
table.clear(globalUpdates)
end
@ -379,8 +352,32 @@ end
@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
startInterval(flush)
if not game then
return
end
local timer = 0
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
if config.interval < 0 then
return
end
timer += deltaTime
if timer >= config.interval then
timer = 0
debug.profilebegin("CharmSync")
flush()
debug.profileend()
end
end)
end
--[[

View file

@ -18,13 +18,4 @@ export type SyncPayload =
]]
export type None = { __none: "__none" }
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
return {}

View file

@ -2,6 +2,14 @@ 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.
@ -9,15 +17,15 @@ type Atom<T> = Charm.Atom<T>
@param input A nested table.
@return A flattened table with slash-separated keys.
]]
local function flatten<Flattened>(input: { [any]: any }): Flattened
local result = {}
local function flatten(input: NestedTable): FlattenedTable
local result: FlattenedTable = {}
local function visit(node: { [any]: any }, path: string)
local function visit(node: NestedTable, path: string)
for key, value in node do
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
local location = if path == "" then key else path .. "/" .. key
if type(value) == "table" then
visit(value, location)
visit(value :: NestedTable, location)
else
result[location] = value
end
@ -26,7 +34,7 @@ local function flatten<Flattened>(input: { [any]: any }): Flattened
visit(input, "")
return result :: any
return result
end
--[[
@ -45,13 +53,12 @@ local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T)
end
--[[
Returns a deep copy of the given table. Skips tables with metatables to
prevent copying reactive proxies.
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" and getmetatable(value) == nil then
if type(value) == "table" then
copy[key] = deepCopy(value)
end
end

View file

@ -1,18 +1,18 @@
local function isTableUnsafe(object: { [any]: any })
local firstKey = next(object)
local key = next(object)
if firstKey == nil then
if key == nil then
return false
end
local keyType = type(firstKey)
local keyType = type(key)
local maxKey = 0
-- Keys must all be the same type
for key in object do
if type(key) ~= keyType then
return true
elseif type(key) == "number" and key > maxKey then
elseif keyType == "number" and key > maxKey then
maxKey = key
end
end
@ -31,11 +31,13 @@ end
with remote argument limitations, such as sparse arrays or mixed tables.
@param value The table value.
@param key The key of the value.
]]
local function validate(value: { [any]: any })
if isTableUnsafe(value) then
warn(
"Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The table is a sparse array with non-sequential indices\n"
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@rbxts/charm",
"version": "0.11.1",
"version": "0.11.0-rc.2",
"description": "Atomic state management based on alien-signals",
"license": "MIT",
"main": "src/init.luau",
@ -11,14 +11,14 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"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

@ -1,14 +1,16 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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
@ -239,11 +241,8 @@ test("should handle side effect with inner effects", function()
table.clear(order)
setB(1)
setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
-- DEVIATION: This assertion was moved outside of the effect so that it
-- runs after the batch updates have been processed
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
test("should handle flags are indirectly updated during checkDirty", function()
@ -271,52 +270,33 @@ test("should handle flags are indirectly updated during checkDirty", function()
assert(triggers == 2, "did not update indirectly")
end)
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
local a, setA = signal(0)
local b, setB = signal(0)
local c, setC = signal(0)
local d = computed(function()
c()
return 0
end)
test("should handle effect recursion for the first execution", function()
local src1, setSrc1 = signal(0)
local src2, setSrc2 = signal(0)
local effect3Executed = false
local triggers1 = 0
local triggers2 = 0
effect(function()
a()
triggers1 += 1
setSrc1(math.min(src1() + 1, 5))
end)
effect(function()
if a() == 2 then
error("Error in effect 2")
end
end)
effect(function()
a()
d()
effect3Executed = true
end)
effect(function()
b()
triggers2 += 1
setSrc2(math.min(src2() + 1, 5))
src2()
end)
setA(1)
effect3Executed = false
local success, err = pcall(setA, 2)
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
setB(1)
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
setC(1)
assert(effect3Executed, "effect 3 should have executed after updating signal c")
assert(triggers1 == 1, `bad triggers1: {triggers1}`)
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
end)
test("should allow finite recursion", function()
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)
@ -408,186 +388,4 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 0, "should not run after b update")
end)
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
local a, setA = signal(0)
local calls = {}
effect(function()
table.insert(calls, a())
return function()
setA(2)
end
end)
assert(#calls == 1, "should have run once initially")
table.clear(calls)
setA(1)
assert(#calls == 1, "should run once after an update")
assert(calls[1] == 2, `bad call: {calls[1]}`)
end)
test("should run the cleanup if the effect disposes itself", function()
local a, setA = signal(0)
local calls = 0
local dispose: () -> ()
dispose = effect(function()
if a() > 0 then
dispose()
return function()
calls += 1
end
end
end)
assert(calls == 0, "should not have called cleanup initially")
setA(1)
assert(calls == 1, "should have called cleanup once after first update")
setA(2)
assert(calls == 1, "should not call cleanup again after second update")
end)
test("should not run the effect if the cleanup function disposes it", function()
local a, setA = signal(0)
local calls = 0
local dispose: () -> ()
dispose = effect(function()
a()
calls += 1
return function()
dispose()
end
end)
assert(calls == 1, "should have run effect once initially")
setA(1)
assert(calls == 1, "should not run effect again after update")
end)
test("should reset the cleanup if the effect throws", function()
local a, setA = signal(0)
local calls = 0
effect(function()
if a() == 0 then
return function()
calls += 1
end
else
error("hello")
end
end)
assert(calls == 0, "should not have called cleanup initially")
local success, err = pcall(setA, 1)
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
assert(calls == 1, "should have called cleanup once after throwing")
setA(0)
assert(calls == 1, "should not call cleanup again after resetting value")
end)
test("recursive effects should run and cleanup in order", function()
local a, setA = signal(0)
local order: { string } = {}
effect(function()
local value = a()
table.insert(order, `+{value}`)
if value < 3 then
setA(value + 1)
end
return function()
table.insert(order, `-{value}`)
end
end)
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
table.clear(order)
setA(100)
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
end)
test("should effects dispose in order", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect = effect(function()
src()
for i = 1, 3 do
effect(function()
return function()
table.insert(order, i)
end
end)
end
end)
assert(#order == 0, "should not cleanup immediately")
setSrc(true)
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test("should handle inner effect clearing outer effect's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
end)
setSrc(true)
end)
test.skip("should handle inner effect cleanup disposing outer effect", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
table.insert(order, 0)
for i = 1, 4 do
effect(function()
return function()
table.insert(order, i)
stopEffect()
end
end)
end
end)
assert(table.concat(order) == "0", "should not cleanup immediately")
table.clear(order)
setSrc(true)
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test.skip("should old inner effect dispose before outer effect re-runs", function()
local order: { string } = {}
local src, setSrc = signal(false)
effect(function()
src()
table.insert(order, "outer")
effect(function()
return function()
table.insert(order, "inner")
end
end)
end)
table.clear(order)
setSrc(true)
assert(table.concat(order) == "innerouter", `bad rerun order: {table.concat(order)}`)
end)
return {}

View file

@ -1,10 +1,11 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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)
@ -31,8 +32,8 @@ end)
test("should run indirect scope cleanup", function()
local cleanups = 0
local cleanup: () -> ()
local cleanup
local stopScope = effectScope(function()
cleanup = effectScope(function()
onCleanup(function()
@ -50,8 +51,8 @@ end)
test("should run indirect effect cleanup", function()
local cleanups = 0
local a, setA = signal(false)
local a, setA = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
@ -70,26 +71,13 @@ test("should run indirect effect cleanup", function()
assert(cleanups == 2, "should not cleanup after update")
end)
test("should dispose inner effects if created in an effect", function()
local source, setSource = signal(1)
local triggers = 0
local dispose: () -> ()
effect(function()
dispose = effectScope(function()
effect(function()
source()
triggers += 1
end)
test("should disallow yielding", function()
local success = pcall(function()
effectScope(function()
(if task then task.wait else coroutine.yield :: never)()
end)
assert(triggers == 1, "did not run initially")
end)
setSource(2)
assert(triggers == 2, "did not run after update")
dispose()
setSource(3)
assert(triggers == 2, "ran after dispose")
assert(not success, "should error on yield")
end)
return {}

View file

@ -1,15 +1,22 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local Charm = require("../")
local suite = require("../../../../test/suite")
local signal = Charm.signal
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)
@ -26,7 +33,7 @@ end
immediately closing it, causing alien-signals to unlink the computed value
from its dependencies and allow it to be garbage collected.
]]
local function unlink(getter: () -> unknown)
local function unlink(getter: () -> ())
effectScope(function()
getter()
end)()
@ -34,14 +41,14 @@ end
test("should release computed cache", function()
local srcRef = weakRef({})
local src, setSrc = signal(srcRef.value)
local src = atom(srcRef.value)
local c = computed(function()
return src()
end)
c() -- cache src value
setSrc(nil) -- release value
unlink(c) -- release computed
src(nil) -- release value
c = unlink(c) -- release computed
gc()
assert(srcRef.value == nil, "should release computed cache")
@ -49,7 +56,7 @@ end)
test("should not release effect for atom", function()
local effectCalls = 0
local src, setSrc = signal(0)
local src = atom(0)
effect(function()
effectCalls += 1
@ -58,7 +65,7 @@ test("should not release effect for atom", function()
assert(effectCalls == 1, "should call effect once")
gc()
setSrc(src() + 1)
src(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)

View file

@ -1,8 +1,10 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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)
@ -25,23 +27,32 @@ test("should listen to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
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 once on change")
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")
setA(2)
assert(runs == 3, "should run on a change")
assert(cleanups == 1, "should not cleanup second call")
end)
test("should run if first value is nil", function()
@ -57,6 +68,36 @@ test("should run if first value is nil", function()
assert(runs == 2, "should run once on change")
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)

View file

@ -1,8 +1,9 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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 })
@ -104,25 +105,4 @@ test("should map keys", function()
assert(state.D == 4, "should add d")
end)
test("should remove old key if mapper returns a new key", function()
local src, setSrc = signal({ 1 })
local map = mapped(src, function(value: number, index: number)
return true, `{index}={value}`
end)
local state = map()
assert(state["1=1"], "should have mapped first element to key 1=1")
setSrc({ 2 })
state = map()
assert(state["1=1"] == nil, "should have removed old key 1=1")
assert(state["1=2"], "should have remapped element to key 1=2")
setSrc({ 3, 1 })
state = map()
assert(state["1=2"] == nil, "should have removed old key 1=2")
assert(state["1=3"], "should have remapped element to key 1=3")
assert(state["2=1"], "should have mapped second element to key 2=1")
end)
return {}

View file

@ -1,18 +1,19 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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: number)
observe(items, function(_, key)
local get = computed(function()
return items()[key]
end)
@ -91,11 +92,11 @@ end)
test("should handle recursive additions and removals", function()
local items, setItems = signal({ 1 })
local edits: { string } = {}
local edits = {}
observe(items, function(item)
if item < 5 then
setItems(function(state: { number })
setItems(function(state)
state = table.clone(state)
table.insert(state, item + 1)
return state
@ -114,10 +115,10 @@ end)
test("should handle self removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits: { string } = {}
local edits = {}
observe(items, function(item, index)
setItems(function(state: { number })
setItems(function(state)
state = table.clone(state)
state[index] = nil
return state
@ -134,7 +135,7 @@ end)
test("should allow disposal during addition", function()
local items, setItems = signal({})
local edits = {}
local dispose: () -> ()
local dispose
dispose = observe(items, function(item)
if item == 1 then
@ -153,7 +154,7 @@ end)
test("should allow disposal during removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {}
local dispose: () -> ()
local dispose
dispose = observe(items, function(item)
table.insert(edits, `+{item}`)
@ -167,32 +168,4 @@ test("should allow disposal during removal", function()
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
end)
test("should not hang on error after recursive update", function()
local items, setItems = signal({})
observe(items, function()
setItems({ {} })
error("test error")
end)
local success, err = pcall(function()
setItems({ {} })
end)
assert(not success, "should error")
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
end)
test("should not hang on error after recursive init", function()
local items, setItems = signal({ {} })
local success, err = pcall(function()
observe(items, function()
setItems({ {} })
error("test error")
end)
end)
assert(not success, "should error")
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
end)
return {}

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

@ -1,12 +1,13 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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: number, incoming: number)
local src, setSrc = signal(0, function(current, incoming)
return current == incoming or incoming % 2 == 1
end)
@ -46,22 +47,4 @@ test("should not freeze values with metatables", function()
)
end)
test("should setter return same value as getter even if equal", function()
local src, setSrc = signal(0, function()
return true
end)
assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`)
end)
test("should setter return latest value", function()
local src, setSrc = signal(0)
effect(function()
if src() == 1 then
setSrc(2)
end
end)
assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`)
assert(src() == 2, `src() expected 2, got {src()}`)
end)
return {}

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

@ -1,9 +1,12 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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)
@ -26,27 +29,40 @@ test("should subscribe to selector", function()
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
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: { number } = {}
local order = {}
local n1, setN1 = signal(0)
local n2, setN2 = signal(0)
local sum = computed(function()
@ -71,8 +87,35 @@ test("should ensure correct execution order", function()
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
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: { "even" | "odd" } = {}
local order = {}
local a, setA = signal(0)
subscribe(function(): "even" | "odd"

View file

@ -1,9 +1,11 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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
@ -32,7 +34,7 @@ local effect = Charm.effect
SOFTWARE
]]
test.describe("graph updates", function()
describe("graph updates", function()
test("should drop A->B->A updates", function()
-- A
-- / |
@ -439,19 +441,21 @@ test.describe("graph updates", function()
end)
end)
test.describe("error handling", function()
describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
local a = atom(0)
local b = computed(function(): nil
local b = computed(function()
error("fail")
end)
local c = computed(function()
return a()
end)
local success, result = pcall(b)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(tostring(result), "fail"), "should throw fail")
assert(string.match(result, "fail"), "should throw fail")
a(1)
assert(c() == 1, "c should recover and return 1")
@ -459,7 +463,7 @@ test.describe("error handling", function()
test("should keep graph consistent on errors in computeds", function()
local a = atom(0)
local b = computed(function(): number
local b = computed(function()
if a() == 1 then
error("fail")
end
@ -472,9 +476,11 @@ test.describe("error handling", function()
assert(c() == 0, "c should be 0")
a(1)
local success, result = pcall(b)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(tostring(result), "fail"), "should throw fail")
assert(string.match(result, "fail"), "should throw fail")
a(2)
assert(c() == 2, "c should recover and return 2")

View file

@ -1,18 +1,20 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local Charm = require("../")
local suite = require("../../../../test/suite")
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local signal = Charm.signal
local subscribe = Charm.subscribe
local trigger = Charm.trigger
local test = suite.test
local beforeEach = suite.beforeEach
local afterEach = suite.afterEach
test.beforeEach(function()
beforeEach(function()
Charm.flags.frozen = false
end)
test.afterEach(function()
afterEach(function()
Charm.flags.frozen = true
end)
@ -67,22 +69,6 @@ test("should trigger effect once", function()
assert(triggers == 2, "effect should run twice")
end)
test("should not notify the trigger function sub", function()
local src1 = signal({} :: { number })
local src2 = computed(function()
return src1()
end)
effect(function()
src1()
src2()
end)
trigger(function()
src1()
src2()
end)
end)
test("should trigger updates for subscribe", function()
local src = signal({} :: { number })
local triggers = 0
@ -91,21 +77,9 @@ test("should trigger updates for subscribe", function()
triggers += 1
end)
table.insert(src(), 1)
trigger(src)
assert(triggers == 1, "subscribe callback should run after trigger")
end)
test("should trigger updates for listen", function()
local src = signal({} :: { number })
local triggers = 0
listen(src, function()
triggers += 1
end)
assert(triggers == 1, "listen callback should run once initially")
trigger(src)
assert(triggers == 2, "listen callback should run after trigger")
end)
return {}

View file

@ -1,11 +1,12 @@
local Charm = require("../src")
local test = require("../../../tests/test")
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)
@ -38,7 +39,7 @@ end)
test("should run detached from effect scope", function()
local effects = 0
local stopEffect: () -> ()
local stopEffect
local stopScope = effectScope(function()
untracked(function()

View file

@ -1,15 +1,15 @@
type Key = string | number | symbol;
type Cleanup = () => void;
export enum ReactiveFlags {
None = 0b000000,
Mutable = 0b000001,
Watching = 0b000010,
RecursedCheck = 0b000100,
Recursed = 0b001000,
Dirty = 0b010000,
Pending = 0b100000,
None = 0b0000000,
Mutable = 0b0000001,
Watching = 0b0000010,
RecursedCheck = 0b0000100,
Recursed = 0b0001000,
Dirty = 0b0010000,
Pending = 0b0100000,
Peeking = 0b1000000,
}
export interface ReactiveNode {
@ -31,15 +31,11 @@ export interface Link {
}
export interface Atom<T> {
(update: Update<T>): T;
(newValue: T | ((currentValue: T) => T)): T;
(): T;
}
export type Getter<T> = () => T;
export type Setter<T> = (update: Update<T>) => T;
export type Update<T> = ((currentValue: T) => T) | T;
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
export type Equals<T> = (current: T, incoming: T) => boolean;
@ -50,12 +46,12 @@ export const flags: {
/**
* Enforces synchronous, non-yielding behavior in signals and effects.
* Also enables state validation in Charm Sync to catch sync errors early.
* Enabled by default in Roblox Studio.
* Enabled if the optimization level is below 2, which is true in studio.
*/
strict: boolean;
/**
* Enforces immutability of tables by deep-freezing table values. Enabled
* by default in Roblox Studio.
* if the optimization level is below 2, which is true in studio.
*/
frozen: boolean;
/**
@ -75,8 +71,13 @@ export const flags: {
* @returns A tuple containing the getter and setter functions for the signal.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
*/
export function signal<T>(initialValue: T, equals?: Equals<T>): LuaTuple<[getter: Getter<T>, setter: Setter<T>]>;
export function signal<T>(): LuaTuple<[getter: Getter<T | undefined>, setter: Setter<T | undefined>]>;
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.
@ -98,7 +99,7 @@ export function atom<T>(): Atom<T | undefined>;
* @param getter A function that produces the next value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
*/
export function computed<T>(getter: (previousValue?: T) => T): Getter<T>;
export function computed<T>(getter: (previousValue?: T) => T): () => T;
/**
* Creates an effect that runs a callback in response to signal state changes.
@ -149,12 +150,10 @@ export function trigger(fn: () => void): void;
export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
/**
* Runs the function without subscribing to signal updates and prevents the
* current effect or scope from tracking inner effects created within the
* function.
* Runs the function without subscribing to signal updates or capturing inner
* effects in the parent effect or scope.
*
* To avoid tracking signals while still allowing inner effects to be tracked
* by parent effects/scopes, use `effectScope()` instead.
* 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.
@ -163,6 +162,19 @@ export function onCleanup(fn: Cleanup, failSilently?: boolean): void;
*/
export function untracked<Args extends any[], Result>(fn: (...args: Args) => Result, ...args: Args): Result;
/**
* 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.
@ -179,51 +191,54 @@ export function batch<Args extends any[], Result>(fn: (...args: Args) => Result,
* when the value returned by the getter changes. The callback receives the new
* 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 callback The function to run when the value changes.
* @param fn The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/
export function listen<T>(getter: Getter<T>, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
export function listen<T>(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup;
/**
* Creates an effect that only runs the callback when the value returned by the
* 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 callback The function to run when the value changes.
* @param fn The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
*/
export function subscribe<T>(getter: Getter<T>, callback: (value: T, previousValue: T) => void): Cleanup;
export function subscribe<T>(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup;
/**
* Creates a new read-only signal that is computed by mapping over the entries
* of the source table. If the key is omitted from the transform function's
* return value, the original key is preserved.
* of an existing atom. If the key is omitted from the mapper's return value,
* the original key is preserved.
*
* This function is optimized to minimize unnecessary updates by only calling
* the transform function for entries that have changed since the last run.
*
* @param getter A signal or function that returns a table
* @param transform Function that maps each entry from the source to a new entry in the mapped table
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-transform
* @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: Getter<Map<KI, VI> | ReadonlyMap<KI, VI>>,
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): Getter<ReadonlyMap<KO, VO>>;
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: Getter<readonly VI[]>,
transform: (value: VI, key: number) => LuaTuple<[VO, K]> | VO,
): Getter<K extends number ? readonly VO[] : { readonly [P in K]: VO }>;
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: Getter<{ readonly [K in KI]: VI }>,
transform: (value: VI, key: KI) => LuaTuple<[VO, KO]> | VO,
): Getter<{ readonly [K in KO]: VO }>;
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
@ -237,44 +252,68 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
* from the table or when the callback is disposed.
*
* @param getter A function that returns the table to observe.
* @param callback A function that is called for each key in the table.
* @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: Getter<Map<K, V> | ReadonlyMap<K, V> | readonly V[]>,
callback: (value: V, key: K) => Cleanup | void,
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: Getter<{ readonly [K in Key]: V }>,
callback: (value: V, key: K) => Cleanup | void,
getter: () => { readonly [K in Key]: V },
fn: (value: V, key: K) => Cleanup | void,
): Cleanup;
/**
* Returns the currently active subscriber (effect or computed node).
* Disables recursive checks for the current effect or computed signal.
* Useful for effects that intentionally update signals they depend on.
*
* @return Current active subscriber or `nil`
* @example
* ```ts
* effect(() => {
* recursive();
* setCount(getCount() + 1);
* })
* ```
*/
export function recursive(): void;
/**
* 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;
/**
* Sets the currently active subscriber (effect or computed node) and returns
* the previous subscriber.
*
* @param sub New subscriber to set as active
* @return Previous active subscriber
*/
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
/**
* Starts a batch operation, incrementing the batch depth counter. Effects
* will not run until the outermost batch ends.
*/
export function startBatch(): void;
/**
* Ends a batch operation, decrementing the batch depth counter. If this is
* the outermost batch, queued effects are flushed and run.
*/
export function endBatch(): void;

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,28 @@
--[[
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 = {
@ -28,15 +50,16 @@ type Stack<T> = {
export type ReactiveFlags = number
local NONE = 0b000000
local MUTABLE = 0b000001
local WATCHING = 0b000010
local RECURSED_CHECK = 0b000100
local RECURSED = 0b001000
local DIRTY = 0b010000
local PENDING = 0b100000
local 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 = table.freeze({
local ReactiveFlags = {
None = NONE,
Mutable = MUTABLE,
Watching = WATCHING,
@ -44,160 +67,161 @@ local ReactiveFlags = table.freeze({
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 update: (sub: ReactiveNode) -> boolean
local notify: (sub: ReactiveNode) -> ()
local unwatched: (sub: ReactiveNode) -> ()
local function isValidLink(checkLink: Link, sub: ReactiveNode): boolean
local link = sub.depsTail
while link do
if link == checkLink then
return true
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
link = link.prevDep
return false
end
return false
end
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number)
local prevDep = sub.depsTail
if prevDep and prevDep.dep == dep then
return
end
local nextDep = if prevDep then prevDep.nextDep else sub.deps
if nextDep and nextDep.dep == dep then
nextDep.version = version
sub.depsTail = nextDep
return
end
local prevSub = dep.subsTail
if prevSub and prevSub.version == version and prevSub.sub == sub then
return
end
local newLink: Link = {
version = version,
dep = dep,
sub = sub,
prevDep = prevDep,
nextDep = nextDep,
prevSub = prevSub,
}
sub.depsTail = newLink
dep.subsTail = newLink
if nextDep then
nextDep.prevDep = newLink
end
if prevDep then
prevDep.nextDep = newLink
else
sub.deps = newLink
end
if prevSub then
prevSub.nextSub = newLink
else
dep.subs = newLink
end
end
local function unlink(link: Link, sub: ReactiveNode?): Link?
sub = sub or link.sub
local dep = link.dep
local prevDep = link.prevDep
local nextDep = link.nextDep
local nextSub = link.nextSub
local prevSub = link.prevSub
if nextDep then
nextDep.prevDep = prevDep
else
sub.depsTail = prevDep
end
if prevDep then
prevDep.nextDep = nextDep
else
sub.deps = nextDep
end
if nextSub then
nextSub.prevSub = prevSub
else
dep.subsTail = prevSub
end
if prevSub then
prevSub.nextSub = nextSub
else
dep.subs = nextSub
if not nextSub then
unwatched(dep)
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
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)
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
flags = NONE
sub.deps = newLink
end
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub)
if prevSub then
prevSub.nextSub = newLink
else
dep.subs = newLink
end
end
if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs
if subSubs then
propagate(subSubs)
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
link = link.nextSub :: Link
until not link
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
local function shallowPropagate(link: Link)
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub)
end
end
link = link.nextSub :: Link
until not link
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
repeat
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
return true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
shallowPropagate(subs)
if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs
if subSubs then
propagate(subSubs)
end
return true
end
elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if checkDirty(dep.deps :: Link, dep) then
link = link.nextSub :: Link
until not link
end
local function shallowPropagate(link: Link): ()
repeat
local sub = link.sub
local flags = sub.flags
if bit32.band(flags, bit32.bor(PENDING, DIRTY)) == PENDING then
sub.flags = bit32.bor(flags, DIRTY)
if bit32.btest(flags, WATCHING) then
notify(sub)
end
end
link = link.nextSub :: Link
until not link
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
repeat
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
return true
elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
@ -205,33 +229,36 @@ local function checkDirty(link: Link, sub: ReactiveNode): boolean
end
return true
end
else
dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
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
end
link = link.nextDep :: Link
until not link
link = link.nextDep :: Link
until not link
return false
end
return false
end
local function createReactiveSystem(
updateFn: (sub: ReactiveNode) -> boolean,
notifyFn: (sub: ReactiveNode) -> (),
unwatchedFn: (sub: ReactiveNode) -> ()
)
update = updateFn
notify = notifyFn
unwatched = unwatchedFn
return {
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
shallowPropagate = shallowPropagate,
}
end
return {
ReactiveFlags = ReactiveFlags,
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
shallowPropagate = shallowPropagate,
createReactiveSystem = createReactiveSystem,
}

View file

@ -1,220 +0,0 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local signal = Charm.signal
test("should correctly propagate changes through computed core", function()
local src, setSrc = signal(0)
local c1 = computed(function()
return src() % 2
end)
local c2 = computed(function()
return c1()
end)
local c3 = computed(function()
return c2()
end)
c3()
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value")
end)
test("should propagate updated source value through chained computations", function()
local src, setSrc = signal(0)
local a = computed(function()
return src()
end)
local b = computed(function()
return a() % 2
end)
local c = computed(function()
return src()
end)
local d = computed(function()
return b() + c()
end)
assert(d() == 0, "bad initial value")
setSrc(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a, setA = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
assert(d() == false, "bad initial value")
setA(true)
assert(d() == true, "bad updated value")
end)
test("should not update if the signal value is reverted", function()
local times = 0
local src, setSrc = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
setSrc(1)
setSrc(0)
c1()
assert(times == 1, "computed ran when it should not")
end)
test("should receive previous value", function()
local calls = {}
local src, setSrc = signal(0)
local c1 = computed(function(current: number?): number
local incoming = src()
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
end)
effect(function()
table.insert(calls, c1())
end)
setSrc(2)
setSrc(3)
assert(c1() == 2, "should skip odd value")
setSrc(4)
setSrc(5)
assert(c1() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875
test("should chained computeds keep reactivity when computed effect happens", function()
local v = atom("Hello")
local c = computed(function()
v(v() .. " World")
return v()
end)
local d = computed(function()
return c()
end)
local e = computed(function()
return d()
end)
local res = computed(function()
return d() .. " | " .. e()
end)
assert(res() == "Hello World | Hello World", res())
v(v() .. " World")
assert(res() == "Hello World World World World | Hello World World World World", res())
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580
test("should not trigger effect scheduler by recursive computed effect", function()
local v = atom("Hello")
local c = computed(function()
v(v() .. " World")
return v()
end)
local comp = computed(function()
return c()
end)
assert(comp() == "Hello World", comp())
v(v() .. " World")
assert(comp() == "Hello World World World World", comp())
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528
test("should not override queried MaybeDirty result", function()
local v1 = atom(nil :: any)
local v2 = atom(false)
local c1 = computed(function()
local c = v1()
if not v1() then
c = { v = atom(0) }
v1(c)
end
return c.v()
end)
local c2 = computed(function()
if not v2() then
return "no"
end
return if c1() then "yes" else "no"
end)
local c3 = computed(function()
return c2()
end)
c3()
v2(true)
c3()
v1().v(999)
assert(c3() == "yes", "should return correct value after update")
end)
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503
test("should trigger effect even computed already dirty", function()
local calls: { string } = {}
local v = atom(0)
local c1 = computed(function()
if v() == 0 then
v(1)
end
return "foo"
end)
local c2 = computed(function()
return v() .. c1()
end)
effect(function()
table.insert(calls, c2())
end)
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
assert(v() == 1, `expected 1 for v(), got {v()}`)
v(2)
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
assert(v() == 2, `expected 2 for v(), got {v()}`)
end)
test("should handle inner effect clearing outer computed's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
local c = computed(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
return 0
end)
stopEffect = effect(function()
c()
end)
setSrc(true)
end)
return {}

View file

@ -1,200 +0,0 @@
--!optimize 1
local Charm = require("../src")
local test = require("../../../tests/test")
test("should disallow yielding", function()
local success = pcall(function()
Charm.effectScope(function()
coroutine.yield()
end)
end)
assert(not success, "should error on yield")
end)
test.describe("traceback", function()
local DEFAULT_ERROR = "Test error"
local function fail(message: string?)
error(message or DEFAULT_ERROR, 0)
end
local function indirectError(message: string?)
fail(message)
end
local function assertStackTrace(err: unknown, expectation: string?)
expectation = expectation or DEFAULT_ERROR
assert(type(err) == "string", "Expected error to be a string")
assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`)
assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`)
assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`)
end
test("signal update", function()
local _, err = pcall(function()
local _, setSrc = Charm.signal(0)
setSrc(function()
indirectError()
return 1
end)
end)
assertStackTrace(err)
end)
test("atom update", function()
local _, err = pcall(function()
local src = Charm.atom(0)
src(function()
indirectError()
return 1
end)
end)
assertStackTrace(err)
end)
test("computed", function()
local _, err = pcall(function()
local src = Charm.computed(function()
indirectError()
end)
src()
end)
assertStackTrace(err)
end)
test("effect init", function()
local _, err = pcall(function()
Charm.effect(function()
indirectError()
end)
end)
assertStackTrace(err)
end)
test("effect run", function()
local _, err = pcall(function()
local src, setSrc = Charm.signal(0)
Charm.effect(function()
if src() == 1 then
indirectError()
end
end)
setSrc(1)
end)
assertStackTrace(err)
end)
test("effect cleanups", function()
local _, err = pcall(function()
local dispose = Charm.effect(function()
Charm.onCleanup(function()
indirectError("onCleanup")
end)
return function()
indirectError("returned cleanup")
end
end)
dispose()
end)
assertStackTrace(err, "onCleanup")
assertStackTrace(err, "returned cleanup")
end)
test("trigger", function()
local _, err = pcall(function()
Charm.trigger(function()
indirectError()
end)
end)
assertStackTrace(err)
end)
test("untracked", function()
local _, err = pcall(function()
Charm.effectScope(function()
Charm.untracked(function()
indirectError()
end)
end)
end)
assertStackTrace(err)
end)
test("batch", function()
local _, err = pcall(function()
Charm.batch(function()
indirectError()
end)
end)
assertStackTrace(err)
end)
test("subscribe getter", function()
local _, err = pcall(function()
Charm.subscribe(function()
indirectError()
end, function() end)
end)
assertStackTrace(err)
end)
test("subscribe callback", function()
local _, err = pcall(function()
local src, setSrc = Charm.signal(0)
Charm.subscribe(src, function()
indirectError()
end)
setSrc(1)
end)
assertStackTrace(err)
end)
test("listen getter", function()
local _, err = pcall(function()
Charm.listen(function()
indirectError()
end, function() end)
end)
assertStackTrace(err)
end)
test("listen callback", function()
local _, err = pcall(function()
local src, setSrc = Charm.signal(0)
Charm.listen(src, function()
indirectError()
end)
setSrc(1)
end)
assertStackTrace(err)
end)
test("mapped getter", function()
local _, err = pcall(function()
local src = Charm.mapped(function()
indirectError()
return { 1 }
end, function()
return 1
end)
src()
end)
assertStackTrace(err)
end)
test("mapped transform", function()
local _, err = pcall(function()
local src = Charm.mapped(function()
return { 1 }
end, function()
indirectError()
return 1
end)
src()
end)
assertStackTrace(err)
end)
end)
return {}

View file

@ -1,55 +0,0 @@
local test = require("../../tests/test")
test.describe("computed", function()
require("@self/computed.test")
end)
test.describe("effect", function()
require("@self/effect.test")
end)
test.describe("effectScope", function()
require("@self/effectScope.test")
end)
test.describe("error", function()
require("@self/errors.test")
end)
test.describe("gc", function()
require("@self/gc.test")
end)
test.describe("listen", function()
require("@self/listen.test")
end)
test.describe("mapped", function()
require("@self/mapped.test")
end)
test.describe("observe", function()
require("@self/observe.test")
end)
test.describe("signal", function()
require("@self/signal.test")
end)
test.describe("subscribe", function()
require("@self/subscribe.test")
end)
test.describe("topology", function()
require("@self/topology.test")
end)
test.describe("trigger", function()
require("@self/trigger.test")
end)
test.describe("untracked", function()
require("@self/untracked.test")
end)
return {}

View file

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

View file

@ -1,5 +0,0 @@
if not script then
return require("../charm/src")
end
return require(script.Parent.Parent.charm.src)

View file

@ -1,6 +0,0 @@
{
"name": "deep-charm",
"tree": {
"$path": "src"
}
}

View file

@ -1,27 +0,0 @@
{
"name": "@rbxts/deep-charm",
"version": "0.1.1",
"description": "Use Charm with plain Luau tables",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"files": [
"src",
"*.luau",
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
}
}

View file

@ -1,53 +0,0 @@
export type ReactiveProxy<T extends object = object> = T & {
__track: () => void;
__target: T;
__signals: Map<unknown, () => unknown>;
};
/**
* Creates a deep reactive value that tracks properties when they're accessed.
* Returns a proxy object that subscribes to changes on any nested property
* you read, and a function for mutating the original table.
*
* @example
* ```luau
* local users, updateUsers = reactive({
* user = { name = "John", surname = "Doe" },
* })
*
* effect(function()
* print(users.user and `{users.user.name} {users.user.surname}`)
* end)
*
* updateUsers(function(state)
* state.user.name = "Jane"
* state.user.surname = "Smith"
* end)
* ```
*
* @param initialValue The value to make deeply reactive.
* @returns A proxy table and a function to mutate the original table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function reactive<T extends object>(
initialValue: T,
): LuaTuple<[value: T, update: (update: T | ((initialValue: T) => void)) => T]>;
/**
* Determines if the provided value is a reactive proxy created by `reactive()`.
*
* @param value A value to test.
* @returns `true` if the value is a reactive proxy, `false` otherwise.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function isReactive(value: unknown): boolean;
/**
* Unwraps a reactive proxy to get the original table. If the provided value
* is not a proxy, it is returned as-is.
*
* @param value A reactive proxy or a non-proxy value.
* @returns The unwrapped target value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
*/
export function toRaw<T>(value: T): T;

View file

@ -1,165 +0,0 @@
local Charm = require("./Charm")
local computed = Charm.computed
local trigger = Charm.trigger
local getActiveSub = Charm.getActiveSub
export type ReactiveProxy<T = any> = typeof(setmetatable(
{} :: {
__track: () -> (),
__target: T,
__signals: { [unknown]: () -> unknown },
},
{} :: { __index: T }
))
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
--[[
Determines if the provided value is a reactive proxy created by `reactive()`.
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function isReactive(value: any): boolean
return type(value) == "table" and getmetatable(value) == ReactiveProxy
end
--[[
Unwraps a reactive proxy to get the original table. If the provided value
is not a proxy, it is returned as-is.
@param value A reactive proxy or a non-proxy value
@return The source table if `value` is a reactive proxy
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function toRaw<T>(value: T): T
if isReactive(value) then
local proxy = value :: ReactiveProxy<T>
proxy.__track()
return toRaw(proxy.__target)
end
return value
end
--[[
Creates a deep reactive value that tracks properties when they're accessed.
Returns a proxy object that subscribes to changes on any nested property
you read, and a function for mutating the original table.
@example
```luau
local users, updateUsers = reactive({
{ name = "John", surname = "Doe" },
})
effect(function()
print(`{users[1].name} {users[1].surname}`)
end)
updateUsers(function(state)
state[1].name = "Jane"
state[1].surname = "Smith"
end)
```
@param initialValue Source table to make deeply reactive
@return A proxy table that tracks reads to nested properties
@return A function for batching mutations to the source table
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]]
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ())
initialValue = toRaw(initialValue)
local proxy = ReactiveProxy.proxyMap[initialValue]
if not proxy then
local track = computed(function(version: number?)
-- Reference the proxy to avoid GCing while an effect is actively
-- tracking this reactive object.
local _ref = proxy
-- Allow updates from the parent to propagate down when the
-- target is mutated directly through the setter.
if trackParent then
trackParent()
end
return (version or 0) + 1
end)
proxy = setmetatable({
__target = initialValue,
__signals = setmetatable({}, { __mode = "v" }) :: any,
__track = track,
}, ReactiveProxy)
ReactiveProxy.proxyMap[initialValue] = proxy
end
local function setValue(update: ((T) -> ())?)
if update then
update(proxy.__target)
end
trigger(proxy.__track)
end
return proxy :: any, setValue
end
--[[
Wraps a value in `reactive()` if it is a table; otherwise, returns the
value as-is.
@param value Source value to make deeply reactive
@param trackParent Optional tracking function to propagate deep updates
@return A reactive proxy if the value is a table
]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T
end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any
local track = self.__track
local signals = self.__signals
local target = self.__target
if getActiveSub() then
if not signals[key] then
signals[key] = computed(function()
track()
return target[key]
end)
end
signals[key]()
end
return toReactive(target[key], track)
end
function ReactiveProxy.__newindex(self: ReactiveProxy, key: any, value: any)
value = toRaw(value)
if self.__target[key] ~= value then
self.__target[key] = value
trigger(self.__track)
end
end
function ReactiveProxy.__iter(self: ReactiveProxy)
self.__track()
local function nextReactive(target: any, prevKey: any)
local nextKey, nextValue = next(target, prevKey)
return nextKey, toReactive(nextValue, self.__track)
end
return nextReactive, self.__target
end
function ReactiveProxy.__len(self: ReactiveProxy)
self.__track()
return #self.__target
end
return {
reactive = reactive,
isReactive = isReactive,
toRaw = toRaw,
}

View file

@ -1,79 +0,0 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
type ReactiveProxy<T = any> = DeepCharm.ReactiveProxy<T>
local computed = Charm.computed
local effect = Charm.effect
local reactive = DeepCharm.reactive
local function weakRef<T>(value: T): { value: T? }
return setmetatable({ value = value }, { __mode = "v" }) :: any
end
local function gc()
for _ = 1, 2000 do
local _ = table.create(100, 0)
end
end
test("should release inner reactive proxy", function()
local valueRef = weakRef({})
local src: any = reactive({ value = valueRef.value })
local dispose = effect(function()
local _ = src.value -- create proxy
end)
src.value = nil :: any -- release proxy
dispose()
gc()
assert(src.__signals.value == nil, "should release signals")
assert(valueRef.value == nil, "should release inner proxy")
end)
test("should release reactive property deps", function()
local src: any = reactive({ foo = 1 })
local c = computed(function()
return src.foo
end)
c()
assert(src.__signals.foo ~= nil, "should have deps for foo")
c = nil :: any
gc()
assert(src.__signals.foo == nil, "should release deps for foo")
end)
test("should not release effect for reactive", function()
local src = reactive({ foo = 1 })
local calls = 0
effect(function()
calls += 1
local _ = src.foo
end)
assert(calls == 1, "should call effect once")
gc()
src.foo += 1
assert(calls == 2, "should call effect again")
end)
test("should not release tracked reactive", function()
local src = reactive({ users = {} :: { [string]: {}? } })
local user
effect(function()
user = src.users.user
end)
gc()
src.users.user = {}
assert(user ~= nil, "src.users.user should not be released")
end)
return {}

View file

@ -1,11 +0,0 @@
local test = require("../../tests/test")
test.describe("reactive", function()
require(`@self/reactive.test`)
end)
test.describe("gc", function()
require(`@self/gc.test`)
end)
return {}

View file

@ -1,232 +0,0 @@
local Charm = require("../Charm")
local DeepCharm = require("../src")
local test = require("../../../tests/test")
local batch = Charm.batch
local computed = Charm.computed
local effect = Charm.effect
local observe = Charm.observe
local reactive = DeepCharm.reactive
local toRaw = DeepCharm.toRaw
test("should track nested properties", function()
local src, setSrc = reactive({ a = { b = { c = 0 } } })
local calls = {}
effect(function()
table.insert(calls, `{src.a.b.c}`)
end)
setSrc(function(state)
state.a.b.c = 1
end)
src.a.b.c = 2
src.a.b.c = 2
setSrc()
assert(table.concat(calls) == "012", table.concat(calls))
end)
test("should track object type changes", function()
local src = reactive({ a = { b = 0 } } :: { a: any })
local calls = {}
effect(function()
table.insert(calls, tostring(if type(src.a) == "table" then src.a.b else src.a))
end)
src.a = { b = 0 }
src.a = 1
src.a = { b = 2 }
src.a = nil
src.a = 3
assert(table.concat(calls) == "0012nil3", table.concat(calls))
end)
test("should unwrap proxy assignments", function()
local src = reactive({ a = { x = 0 }, b = { x = 0 } })
local runs = 0
effect(function()
local _, _ = src.a.x, src.b.x
runs += 1
end)
src.a, src.b = src.a, src.b
assert(runs == 1, `bad runs: {runs}`)
src.a, src.b = src.b, src.a
assert(runs == 3, `bad runs: {runs}`)
end)
test("should track iteration", function()
local src, setSrc = reactive({} :: { [string]: number })
local calls = {}
effect(function()
for key, value in src do
table.insert(calls, `{key}={value};`)
end
end)
src.a = 1
assert(table.concat(calls) == "a=1;", table.concat(calls))
table.clear(calls)
src.b = 2
assert(table.concat(calls) == "a=1;b=2;", table.concat(calls))
table.clear(calls)
src.a = 2
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
table.clear(calls)
setSrc()
assert(table.concat(calls) == "a=2;b=2;", table.concat(calls))
end)
test("should track iteration for nested tables", function()
local src = reactive({} :: { [string]: { value: number } })
local calls = {}
effect(function()
for key, obj in src do
table.insert(calls, `{key}={obj.value};`)
end
end)
src.a = { value = 1 }
table.clear(calls)
src.b = { value = 1 }
assert(table.concat(calls) == "a=1;b=1;", table.concat(calls))
table.clear(calls)
src.a.value = 2
assert(table.concat(calls) == "a=2;b=1;", table.concat(calls))
end)
test("should track array length", function()
local src = reactive({ array = {} :: { number } })
local calls = {}
effect(function()
table.insert(calls, #src.array)
end)
src.array[#src.array + 1] = 1
src.array[#src.array + 1] = 2
src.array[#src.array + 1] = 3
src.array = { 1, 2, 3, 4 }
assert(table.concat(calls) == "01234", table.concat(calls))
end)
test("should not track unread properties", function()
local src = reactive({ value = { a = 1, b = 2 } })
local calls = 0
effect(function()
local _ = src.value.a
calls += 1
end)
assert(calls == 1, `bad calls: {calls}`)
src.value.b = 3
assert(calls == 1, `bad calls: {calls}`)
end)
test("should overwrite with table passed to setter", function()
local src, setSrc = reactive({ a = 1, b = 2, c = 3 } :: { [string]: number? })
local calls = {}
effect(function()
table.insert(calls, `{src.a}{src.b}{src.c}`)
end)
setSrc(function(src)
src.a = 2
src.b = nil
src.c = 4
end)
assert(table.concat(calls, ",") == "123,2nil4", table.concat(calls, ","))
end)
test("should unwrap proxy", function()
local raw = {}
local src = reactive(raw)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(toRaw(raw) == raw, "unwrap modified a non-proxy value")
assert(runs == 1, `bad runs: {runs}`)
src.x = 1
assert(runs == 2, `bad runs: {runs}`)
end)
test("should unwrap nested proxy", function()
local raw = {}
local inner = reactive(raw)
local src = reactive(inner)
local runs = 0
effect(function()
local _ = toRaw(src)
runs += 1
end)
assert(toRaw(src) == raw, "unwrap did not return the original table")
assert(runs == 1, `bad runs: {runs}`)
inner.x = 1
assert(runs == 2, `bad runs: {runs}`)
src.y = 2
assert(runs == 3, `bad runs: {runs}`)
end)
test("should observe items", function()
local items, updateItems = reactive({ 0, 0, 0 })
local updates = {}
local cleanups = {}
observe(function()
return toRaw(items)
end, function(_, key: number)
local get = computed(function()
return items[key]
end)
effect(function()
table.insert(updates, get())
end)
return function()
table.insert(cleanups, key)
end
end)
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
table.clear(updates)
updateItems(function(state)
state[1] = 1
state[2] = 2
state[3] = 3
end)
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
table.clear(updates)
batch(function()
items[3] = nil
items[4] = 4
end)
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
end)
return {}

View file

@ -1,9 +0,0 @@
[package]
name = "littensy/deep-charm"
version = "0.1.1"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]
[dependencies]
Charm = "littensy/charm@^0.11.0"

View file

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

View file

@ -1 +1,12 @@
type CoreReactBinding<T> = {
getValue: (self: CoreReactBinding<T>) -> T,
_source: string?,
}
type ReactBindingMap = {
map: <T, U>(self: CoreReactBinding<T> & ReactBindingMap, (T) -> U) -> ReactBindingMap & CoreReactBinding<U>,
}
export type Binding<T> = CoreReactBinding<T> & ReactBindingMap
return require(script.Parent.Parent.Parent:WaitForChild("@rbxts-js").React)

View file

@ -1,6 +1,6 @@
{
"name": "@rbxts/react-charm",
"version": "0.4.1",
"version": "0.4.0-rc.2",
"description": "React bindings for Charm state management",
"license": "MIT",
"main": "src/init.luau",
@ -11,20 +11,20 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
"@rbxts/charm": "workspace:*"
},
"peerDependencies": {
"@rbxts/react": ">=17.0.0"
"@rbxts/react": "*"
}
}

View file

@ -1,5 +1,7 @@
local React = require("../../React")
local ReactRoblox = require("../../ReactRoblox")
_G.__ROACT_17_MOCK_SCHEDULER__ = true
local React = require("../../../React")
local ReactRoblox = require("../../../ReactRoblox")
type RenderedHook<Props, Result> = {
result: Result,
@ -13,13 +15,12 @@ local function renderHook<Props, Result>(callback: (Props) -> Result, props: Pro
local function Component(props: Props)
hook.result = callback(props)
return nil
end
function hook.rerender(newProps: Props?)
props = newProps or props
ReactRoblox.act(function()
root:render(React.createElement(Component, props) :: any)
root:render(React.createElement(Component, props))
end)
end

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

@ -1,7 +1,9 @@
local Charm = require("../Charm")
local ReactCharm = require("../src")
local Charm = require("../../Charm")
local ReactCharm = require("../")
local renderHook = require("./helpers/renderHook")
local test = require("../../../tests/test")
local suite = require("../../../../test/suite")
local test = suite.test
test("tracks signal state", function()
local getState, setState = Charm.signal(0)

View file

@ -1,7 +1,9 @@
local Charm = require("../Charm")
local ReactCharm = require("../src")
local Charm = require("../../Charm")
local ReactCharm = require("../")
local renderHook = require("./helpers/renderHook")
local test = require("../../../tests/test")
local suite = require("../../../../test/suite")
local test = suite.test
test("tracks signal state", function()
local getState, setState = Charm.signal(0)

View file

@ -13,12 +13,15 @@ local React = require("./React")
@param dependencies An array of values used to memoize the getter.
@return The current state.
]]
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
local value: T, setValue = React.useState(getter)
local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
local value, setValue = React.useState(getter)
React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setValue :: (T, T?) -> ())
end, dependencies :: { any } or {})
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
@ -34,19 +37,22 @@ end
@param dependencies An array of values used to memoize the getter.
@return A binding that stores the current state.
]]
local function useSignalBinding<T>(getter: () -> T, dependencies: {}?)
local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
local initialValue = React.useMemo(getter, {})
local binding, setBinding = React.useBinding(initialValue)
React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setBinding :: (T, T?) -> ())
end, dependencies :: { any } or {})
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 table.freeze({
return {
useSignalState = useSignalState,
useSignalBinding = useSignalBinding,
useAtom = useSignalState,
})
}

View file

@ -1,13 +0,0 @@
local test = require("../../tests/test")
_G.__ROACT_17_MOCK_SCHEDULER__ = true
test.describe("useSignalState", function()
require("@self/useSignalState.test")
end)
test.describe("useSignalBinding", function()
require("@self/useSignalBinding.test")
end)
return {}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@rbxts/vide-charm",
"version": "0.4.1",
"version": "0.4.0-rc.2",
"description": "Vide bindings for Charm state management",
"license": "MIT",
"main": "src/init.luau",
@ -11,18 +11,18 @@
"!wally.toml",
"!wally.lock",
"!default.project.json",
"!test"
"!**/__tests__"
],
"repository": {
"type": "git",
"url": "git+https://github.com/littensy/charm.git"
},
"scripts": {
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:^"
"@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

@ -1,11 +1,13 @@
local Charm = require("../Charm")
local Vide = require("../Vide")
local VideCharm = require("../src")
local test = require("../../../tests/test")
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: () -> number
local source
local cleanup = Vide.root(function()
source = VideCharm.useSignalState(getState)
end)
@ -17,7 +19,7 @@ end)
test("accepts a selector", function()
local getState, setState = Charm.signal(0)
local source: () -> number
local source
local cleanup = Vide.root(function()
source = VideCharm.useSignalState(function()
return 2 * getState()

View file

@ -11,7 +11,10 @@ local Vide = require("./Vide")
local function useSignalState<State>(getter: () -> State): () -> State
local state = Vide.source(getter())
Vide.cleanup(Charm.listen(getter, state :: (State, State?) -> ()))
Vide.cleanup(Charm.effect(function()
-- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(state :: (State) -> State, getter())
end))
return state
end

View file

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

View file

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

1142
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,2 @@
packages:
- packages/*
publicHoistPattern:
- "*@rbxts*"

View file

@ -1,6 +1,13 @@
# This file lists tools managed by Rokit, a toolchain manager for Roblox projects.
# For more information, see https://github.com/rojo-rbx/rokit
# New tools can be added by running `rokit add <tool>` in a terminal.
[tools]
wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.7.0"
stylua = "johnnymorganz/stylua@2.5.2"
luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
lute = "luau-lang/lute@1.0.0"
rojo = "rojo-rbx/rojo@7.6.1"
stylua = "johnnymorganz/stylua@2.3.1"
selene = "Kampfkarren/selene@0.29.0"
luau-lsp = "johnnymorganz/luau-lsp@1.56.2"
lune = "lune-org/lune@0.10.4"
lute = "luau-lang/lute@0.1.0-nightly.20251119"

6
selene.toml Normal file
View file

@ -0,0 +1,6 @@
std = "selene_defs"
exclude = ["*/node_modules/*"]
[lints]
global_usage = "allow"
shadowing = "allow"

6
selene_defs.yml Normal file
View file

@ -0,0 +1,6 @@
base: roblox
name: selene_defs
globals:
require:
args:
- type: string

23
test/spec.server.luau Normal file
View file

@ -0,0 +1,23 @@
local Charm = require("../packages/charm/src")
local suite = require("./suite")
Charm.flags.strict = true
Charm.flags.frozen = true
suite.describe("charm", function()
require("../packages/charm/src/__tests__/spec")
end)
suite.describe("charm-sync", function()
require("../packages/charm-sync/src/__tests__/spec")
end)
suite.describe.ingame("react-charm", function()
require("../packages/react-charm/src/__tests__/spec")
end)
suite.describe.ingame("vide-charm", function()
require("../packages/vide-charm/src/__tests__/spec")
end)
suite.finish()

1
test/suite Submodule

@ -0,0 +1 @@
Subproject commit eb95781a86e505cb6bff83ca568a67c7295ebb38

View file

@ -1,33 +0,0 @@
local Charm = require("../packages/charm/src")
local test = require("./test")
Charm.flags.strict = true
Charm.flags.frozen = true
local describeInRoblox = test.describeIf(function()
return not not game
end)
test.describe("charm", function()
require("../packages/charm/test")
end)
test.describe("charm-sync", function()
require("../packages/charm-sync/test")
end)
test.describe("deep-charm", function()
require("../packages/deep-charm/test")
end)
describeInRoblox("react-charm", function()
require("../packages/react-charm/test")
end)
describeInRoblox("vide-charm", function()
require("../packages/vide-charm/test")
end)
test.finish()
return {}

@ -1 +0,0 @@
Subproject commit 7ef2b88191af884d543e7392d50c7e2adc484e32

View file

@ -4,10 +4,11 @@
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"jsx": "react",
"jsxFactory": "React.createElement",
"jsxFragmentFactory": "React.Fragment",
"jsxFactory": "Roact.createElement",
"jsxFragmentFactory": "Roact.createFragment",
"module": "commonjs",
"moduleResolution": "Node",
"ignoreDeprecations": "6.0",
"noLib": true,
"resolveJsonModule": true,
"experimentalDecorators": true,