refactor: embrace scope cleanup, restructure code

This commit is contained in:
littensy 2025-05-05 22:05:41 -07:00
parent 3eba56e298
commit 8d385356cd
81 changed files with 687 additions and 626 deletions

View file

@ -16,7 +16,7 @@ jobs:
run: sh bin/check.sh
- name: Run tests
run: sh bin/test.sh
run: lute tests/init.luau
- name: Build models
run: sh bin/build.sh

2
.gitignore vendored
View file

@ -1,7 +1,7 @@
/.vscode
/build
/roblox_packages
/packages/**/wally.lock
/pkgs/**/wally.lock
/sourcemap.json
*.tsbuildinfo
*.rbxm

View file

@ -2,5 +2,9 @@
"languageMode": "strict",
"lint": {
"LocalShadow": false
},
"aliases": {
"tests": "tests",
"pkgs": "packages"
}
}

View file

@ -1,5 +1,5 @@
mkdir build
rojo build packages/charm -o build/charm.rbxm
rojo build packages/charm-sync -o build/charm-sync.rbxm
rojo build packages/react-charm -o build/react-charm.rbxm
rojo build packages/vide-charm -o build/vide-charm.rbxm
rojo build pkgs/charm -o build/charm.rbxm
rojo build pkgs/charm-sync -o build/charm-sync.rbxm
rojo build pkgs/react-charm -o build/react-charm.rbxm
rojo build pkgs/vide-charm -o build/vide-charm.rbxm

View file

@ -1 +0,0 @@
lute tests/spec.server.luau

View file

@ -6,15 +6,26 @@
"$className": "ReplicatedStorage",
"Packages": {
"$path": "roblox_packages",
"Charm": { "$path": "packages/charm" },
"CharmSync": { "$path": "packages/charm-sync" },
"ReactCharm": { "$path": "packages/react-charm" },
"VideCharm": { "$path": "packages/vide-charm" }
"Charm": { "$path": "pkgs/charm" },
"CharmSync": { "$path": "pkgs/charm-sync" },
"ReactCharm": { "$path": "pkgs/react-charm" },
"VideCharm": { "$path": "pkgs/vide-charm" }
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"Tests": { "$path": "tests" }
"Tests": {
"$path": "tests",
"Charm": { "$path": "pkgs/charm/__tests__" },
"CharmSync": { "$path": "pkgs/charm-sync/__tests__" },
"ReactCharm": { "$path": "pkgs/react-charm/__tests__" },
"VideCharm": { "$path": "pkgs/vide-charm/__tests__" }
},
"Benchmarks": {
"$className": "Folder",
"Charm": { "$path": "pkgs/charm/__benchmarks__" },
"CharmSync": { "$path": "pkgs/charm-sync/__benchmarks__" }
}
}
}
}

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,54 +0,0 @@
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
local signal = signals.signal
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function atomOper<T>(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
end
local transform = (...)
local currentState = (source :: any).value
local nextState: T
-- If the transform is a function, call it with the current state and pause
-- tracking to avoid adding signals called by the transform.
if type(transform) == "function" then
pauseTracking()
local success, result = pcall(transform, currentState)
resumeTracking()
if not success then
error(result, 2)
end
nextState = result
else
nextState = transform
end
if equals and equals(currentState, nextState) then
return nextState
end
source(nextState)
return nextState
end
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
local source = signal(state)
local equals = options and options.equals
return function(...)
return atomOper(source, equals, ...)
end
end
return atom

View file

@ -1,27 +0,0 @@
local signals = require("./signals")
local effectImpl = signals.effect
local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
local disposed = false
local dispose: () -> ()
local function disposeSafe()
disposed = true
if dispose then
dispose()
end
end
dispose = effectImpl(function()
return callback(disposeSafe)
end)
if disposed then
dispose()
end
return disposeSafe
end
return effect

View file

@ -1,38 +0,0 @@
local listen = require("./listen")
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local cleanups: { [K]: () -> ()? } = {}
local unsub = listen(selector, function(currentItems, previousItems)
if not previousItems then
for key, value in currentItems do
cleanups[key] = observer(value, key)
end
return
end
for key in previousItems do
local cleanup = cleanups[key]
if currentItems[key] == nil and cleanup then
cleanups[key] = nil
cleanup()
end
end
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key)
end
end
end)
return function()
unsub()
for _, cleanup in cleanups do
(cleanup :: () -> ())()
end
table.clear(cleanups)
end
end
return observe

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.react)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

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

View file

@ -24,14 +24,18 @@ local function update(state)
state.users = table.clone(state.users)
for user = 1, USERS do
if math.random() > 0.5 then
continue
end
local rng = math.random()
state.users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
if rng < 1 / 3 then
continue
elseif rng < 2 / 3 then
state.users[user] = nil
else
state.users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
end
return state

View file

@ -0,0 +1,19 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
spec("client")
spec("server")
spec("flatten")
spec("patch")
spec("validate")
return {}

View file

@ -6,17 +6,12 @@ local computed = Charm.computed
local effect = Charm.effect
local atom = Charm.atom
local cleanup: () -> ()
return {
AfterEach = function()
cleanup()
end,
Functions = {
atom = function()
local state = atom(0)
cleanup = effect(function()
effect(function()
state()
end)
@ -32,7 +27,7 @@ return {
return 2 * state()
end)
cleanup = effect(function()
effect(function()
double()
end)

View file

@ -0,0 +1,63 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local function propagate(w: number, h: number)
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end
local function propagateListen(w: number, h: number)
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
listen(last, function() end)
end
src(src() + 1)
end
return {
Functions = {
["effect 1*100"] = function()
propagate(1, 100)
end,
["effect 100*1"] = function()
propagate(100, 1)
end,
["effect 10*10"] = function()
propagate(10, 10)
end,
["listen 1*100"] = function()
propagateListen(1, 100)
end,
["listen 100*1"] = function()
propagateListen(100, 1)
end,
["listen 10*10"] = function()
propagateListen(10, 10)
end,
},
}

View file

@ -1,13 +1,12 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed
local signal = signals.signal
local test = suite.test
test("should correctly propagate changes through computed signals", function()
local src = signal(0)
local src, setSrc = signal(0)
local c1 = computed(function()
return src() % 2
end)
@ -19,15 +18,15 @@ test("should correctly propagate changes through computed signals", function()
end)
c3()
src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
src(3) -- c1 -> dirty, c2 -> toCheckDirty
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 = signal(0)
local src, setSrc = signal(0)
local a = computed(function()
return src()
end)
@ -42,12 +41,12 @@ test("should propagate updated source value through chained computations", funct
end)
assert(d() == 0, "bad initial value")
src(2)
setSrc(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false)
local a, setA = signal(false)
local b = computed(function()
return a()
end)
@ -61,22 +60,22 @@ test("should handle flags are indirectly updated during checkDirty", function()
end)
assert(d() == false, "bad initial value")
a(true)
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 = signal(0)
local src, setSrc = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
src(1)
src(0)
setSrc(1)
setSrc(0)
c1()
assert(times == 1, "computed ran when it should not")
end)

View file

@ -1,13 +1,13 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local endBatch = signals.endBatch
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local onEffectCleanup = signals.onEffectCleanup
local signal = signals.signal
local startBatch = signals.startBatch
local test = suite.test
@ -15,7 +15,7 @@ local test = suite.test
test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0
local a = signal(1)
local a, setA = signal(1)
local b = computed(function()
bRunTimes += 1
return a() * 2
@ -25,15 +25,15 @@ test("should clear subscriptions when untracked by all subscribers", function()
end)
assert(bRunTimes == 1, "should run initially")
a(2)
setA(2)
assert(bRunTimes == 2, "should run after change")
stopEffect()
a(3)
setA(3)
assert(bRunTimes == 2, "should not run after stop")
end)
test("should not run untracked inner effect", function()
local a = signal(3)
local a, setA = signal(3)
local b = computed(function()
return a() > 0
end)
@ -48,14 +48,14 @@ test("should not run untracked inner effect", function()
end
end)
a(2)
a(1)
a(0)
setA(2)
setA(1)
setA(0)
end)
test("should run outer effect first", function()
local a = signal(1)
local b = signal(1)
local a, setA = signal(1)
local b, setB = signal(1)
effect(function()
if a() > 0 then
@ -69,13 +69,13 @@ test("should run outer effect first", function()
end)
startBatch()
b(0)
a(0)
setB(0)
setA(0)
endBatch()
end)
test("should not trigger inner effect when resolve maybe dirty", function()
local a = signal(0)
local a, setA = signal(0)
local b = computed(function()
return a() % 2
end)
@ -92,12 +92,12 @@ test("should not trigger inner effect when resolve maybe dirty", function()
end)
end)
a(2)
setA(2)
end)
test("should trigger inner effects in sequence", function()
local a = signal(0)
local b = signal(0)
local a, setA = signal(0)
local b, setB = signal(0)
local c = computed(function()
return a() - b()
end)
@ -121,16 +121,16 @@ test("should trigger inner effects in sequence", function()
table.clear(order)
startBatch()
b(1)
a(1)
setB(1)
setA(1)
endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
end)
test("should trigger inner effects in sequence in effect scope", function()
local a = signal(0)
local b = signal(0)
local a, setA = signal(0)
local b, setB = signal(0)
local order: { string } = {}
effectScope(function()
@ -149,8 +149,8 @@ test("should trigger inner effects in sequence in effect scope", function()
table.clear(order)
startBatch()
b(1)
a(1)
setB(1)
setA(1)
endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
@ -170,12 +170,12 @@ test("should custom effect support batch", function()
local logs: { string } = {}
local a = signal(0)
local b = signal(0)
local b, setB = signal(0)
local aa = computed(function()
table.insert(logs, "aa-0")
if a() == 0 then
b(1)
setB(1)
end
table.insert(logs, "aa-1")
return nil
@ -197,8 +197,8 @@ test("should custom effect support batch", function()
end)
test("should duplicate subscribers do not affect the notify order", function()
local src1 = signal(0)
local src2 = signal(0)
local src1, setSrc1 = signal(0)
local src2, setSrc2 = signal(0)
local order: { string } = {}
effect(function()
@ -216,17 +216,17 @@ test("should duplicate subscribers do not affect the notify order", function()
table.insert(order, "b")
src1()
end)
src2(1) -- src1.subs: a -> b -> a
setSrc2(1) -- src1.subs: a -> b -> a
table.clear(order)
src1(src1() + 1)
setSrc1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end)
test("should handle side effect with inner effects", function()
local a = signal(0)
local b = signal(0)
local a, setA = signal(0)
local b, setB = signal(0)
local order: { string } = {}
effect(function()
@ -241,14 +241,14 @@ test("should handle side effect with inner effects", function()
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
table.clear(order)
b(1)
a(1)
setB(1)
setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false)
local a, setA = signal(false)
local b = computed(function()
return a()
end)
@ -268,71 +268,57 @@ test("should handle flags are indirectly updated during checkDirty", function()
triggers += 1
end)
assert(triggers == 1, "did not run initially")
a(true)
setA(true)
assert(triggers == 2, "did not update indirectly")
end)
test("should run effect cleanup", function()
local cleanups = 0
test("should run cleanups in sequence", function()
local order = {}
local a = signal(false)
local cleanup = effect(function()
local a, setA = signal(false)
local stopEffect = effect(function()
a()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
cleanup()
assert(cleanups == 2, "should cleanup after effect stop")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup in scope", function()
local cleanups = 0
local a = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
return function()
cleanups += 1
end
onEffectCleanup(function()
table.insert(order, "a")
end)
onEffectCleanup(function()
table.insert(order, "b")
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
assert(table.concat(order) == "", "should not cleanup immediately")
setA(true)
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
table.clear(order)
setA(false)
assert(table.concat(order) == "", "should not cleanup after update")
end)
test("should run inner effect cleanup", function()
local cleanups = 0
local a = signal(false)
local stopScope = effect(function()
local a, setA = signal(false)
local stopEffect = effect(function()
a()
effect(function()
return function()
onEffectCleanup(function()
cleanups += 1
end
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
setA(true)
assert(cleanups == 1, "should cleanup after outer update")
stopScope()
stopEffect()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
setA(false)
assert(cleanups == 2, "should not cleanup after update")
end)

View file

@ -1,14 +1,15 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local signals = charm.signals
local effect = signals.effect
local effectScope = signals.effectScope
local signal = signals.signal
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local test = suite.test
test("should not trigger after stop", function()
local count = signal(1)
local count, setCount = signal(1)
local triggers = 0
@ -19,42 +20,26 @@ test("should not trigger after stop", function()
end)
assert(triggers == 1, "did not run initially")
count(2)
setCount(2)
assert(triggers == 2, "did not run after update")
end)
count(3)
setCount(3)
assert(triggers == 3, "did not run after update outside scope")
stopScope()
count(4)
setCount(4)
assert(triggers == 3, "ran after stop")
end)
test("should run effect cleanup", function()
local cleanups = 0
local cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
cleanup()
assert(cleanups == 1, "should cleanup after effect stop")
cleanup()
assert(cleanups == 1, "should skip redundant cleanup")
end)
test("should run indirect effect cleanup", function()
test("should run indirect scope cleanup", function()
local cleanups = 0
local cleanup
local stopScope = effectScope(function()
cleanup = effectScope(function()
return function()
onScopeDispose(function()
cleanups += 1
end
end)
end)
end)
@ -65,4 +50,26 @@ test("should run indirect effect cleanup", function()
assert(cleanups == 1, "should skip redundant dispose")
end)
test("should run indirect effect cleanup", function()
local cleanups = 0
local a, setA = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
onEffectCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
setA(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
setA(false)
assert(cleanups == 2, "should not cleanup after update")
end)
return {}

View file

@ -1,7 +1,6 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed
local effect = signals.effect
local pauseTracking = signals.pauseTracking
@ -92,7 +91,7 @@ local function reaction<T>(
end
test("#48", function()
local source = signal(0)
local source, setSource = signal(0)
local disposeInner: () -> ()
reaction(function()
@ -107,9 +106,9 @@ test("#48", function()
end
end)
source(1)
source(2)
source(3)
setSource(1)
setSource(2)
setSource(3)
end)
return {}

View file

@ -0,0 +1,18 @@
local suite = require("@tests/suite")
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
spec("computed")
spec("effect")
spec("effectScope")
spec("issue_48")
spec("topology")
spec("untrack")
return {}

View file

@ -1,7 +1,6 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed
local effect = signals.effect
local signal = signals.signal
@ -44,7 +43,7 @@ describe("graph updates", function()
-- C
-- |
-- D
local a = signal(2)
local a, setA = signal(2)
local b = computed(function()
return a() - 1
@ -65,7 +64,7 @@ describe("graph updates", function()
assert(computeCalls == 1, "compute should be called once")
computeCalls = 0
a(4)
setA(4)
d()
assert(computeCalls == 1, "compute should drop update")
end)
@ -79,7 +78,7 @@ describe("graph updates", function()
-- \ /
-- D
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
return a()
end)
@ -97,7 +96,7 @@ describe("graph updates", function()
assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
setA("aa")
assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
@ -112,7 +111,7 @@ describe("graph updates", function()
-- |
-- E
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
return a()
end)
@ -134,7 +133,7 @@ describe("graph updates", function()
assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
setA("aa")
assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
@ -142,7 +141,7 @@ describe("graph updates", function()
test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes
-- A->B->C
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
a()
return "foo"
@ -158,7 +157,7 @@ describe("graph updates", function()
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
setA("aa")
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out")
end)
@ -174,7 +173,7 @@ describe("graph updates", function()
-- E
-- / \
-- F G
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
return a()
@ -223,7 +222,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
a("b")
setA("b")
assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update")
@ -238,7 +237,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
a("c")
setA("c")
assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update")
@ -259,7 +258,7 @@ describe("graph updates", function()
-- *A
-- / \
-- *B C <- we don't listen to C
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
return a()
@ -274,7 +273,7 @@ describe("graph updates", function()
assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run")
a("aa")
setA("aa")
assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run")
end)
@ -288,7 +287,7 @@ describe("graph updates", function()
-- *B D <- we don't listen to C
-- |
-- *C
local a = signal("a")
local a, setA = signal("a")
local spyBCalls = 0
local spyB = function()
spyBCalls += 1
@ -319,7 +318,7 @@ describe("graph updates", function()
spyCCalls = 0
unsub()
a("aa")
setA("aa")
assert(spyBCalls == 0, "b should not run")
assert(spyCCalls == 0, "c should not run")
@ -336,7 +335,7 @@ describe("graph updates", function()
-- B *C <- returns same value every time
-- \ /
-- D
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
return a()
end)
@ -354,7 +353,7 @@ describe("graph updates", function()
assert(d() == "a c", "d should be 'a c'")
spyResult = nil
a("aa")
setA("aa")
d()
assert(spyResult == "aa c", "d should have returned 'aa c'")
end)
@ -368,7 +367,7 @@ describe("graph updates", function()
-- B *C *D
-- \ | /
-- E
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
return a()
end)
@ -390,13 +389,13 @@ describe("graph updates", function()
assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil
a("aa")
setA("aa")
e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end)
test("should support lazy branches", function()
local a = signal(0)
local a, setA = signal(0)
local b = computed(function()
return a()
end)
@ -405,10 +404,10 @@ describe("graph updates", function()
end)
assert(c() == 0, "c should be 0")
a(1)
setA(1)
assert(c() == 1, "c should be 1")
a(0)
setA(0)
assert(c() == 0, "c should be 0 again")
end)
@ -420,7 +419,7 @@ describe("graph updates", function()
-- *B *C
-- \ /
-- D
local a = signal("a")
local a, setA = signal("a")
local b = computed(function()
a()
return "b"
@ -439,14 +438,14 @@ describe("graph updates", function()
assert(d() == "b c", "d should be 'b c'")
spyCalls = 0
a("aa")
setA("aa")
assert(spyCalls == 0, "d should not run")
end)
end)
describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
local a = signal(0)
local a, setA = signal(0)
local b = computed(function()
error("fail")
end)
@ -460,12 +459,12 @@ describe("error handling", function()
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
a(1)
setA(1)
assert(c() == 1, "c should recover and return 1")
end)
test("should keep graph consistent on errors in computeds", function()
local a = signal(0)
local a, setA = signal(0)
local b = computed(function()
if a() == 1 then
error("fail")
@ -478,14 +477,14 @@ describe("error handling", function()
assert(c() == 0, "c should be 0")
a(1)
setA(1)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
a(2)
setA(2)
assert(c() == 2, "c should recover and return 2")
end)
end)

View file

@ -1,7 +1,6 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = require("../../src/signals")
local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
@ -9,7 +8,7 @@ local signal = signals.signal
local test = suite.test
test("should pause tracking", function()
local src = signal(0)
local src, setSrc = signal(0)
local c = computed(function()
pauseTracking()
local value = src()
@ -18,7 +17,7 @@ test("should pause tracking", function()
end)
assert(c() == 0, "should not track src")
src(1)
setSrc(1)
assert(c() == 0, "should not track src")
end)

View file

@ -0,0 +1,20 @@
local suite = require("@tests/suite")
local describe = suite.describe
local function collect(name: string)
describe(name, function()
return require(`./{name}/spec`) :: any
end)
end
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
collect("signals")
spec("subscribe")
return {}

View file

@ -0,0 +1,28 @@
local atom = require("../src/atom")
local subscribe = require("../src/subscribe")
local suite = require("@tests/suite")
local test = suite.test
test("should subscribe to atom", function()
local source = atom(0)
local current, previous
local unsub = subscribe(source, function(a, b)
current = a
previous = b
end)
assert(not current and not previous, "should not run initially")
source(1)
assert(current == 1, "should run with new value")
assert(previous == 0, "should run with old value")
source(2)
assert(current == 2, "should run with new value 2")
assert(previous == 1, "should run with old value 2")
unsub()
source(3)
assert(current == 2, "should not run after unsubscribing")
end)
return {}

54
pkgs/charm/src/atom.luau Normal file
View file

@ -0,0 +1,54 @@
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
local signal = signals.signal
local function atomGetterSetterEquals<T>(
source: () -> T,
setSource: (((T) -> T) | T) -> T,
equals: (T, T) -> boolean
): Atom<T>
return function(...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
end
local nextState = (...)
if type(nextState) == "function" then
return setSource(function(currentState): T
local nextState = nextState(currentState)
return if equals(currentState, nextState) then currentState else nextState
end)
else
return setSource(function(currentState): T
return if equals(currentState, nextState) then currentState else nextState
end)
end
end
end
local function atomGetterSetter<T>(source: () -> T, setSource: (((T) -> T) | T) -> T): Atom<T>
return function(...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
else
return setSource((...))
end
end
end
local function atom<T>(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom<T>
local source, setSource = signal(state)
if equals then
return atomGetterSetterEquals(source, setSource, equals)
else
return atomGetterSetter(source, setSource)
end
end
return atom

View file

@ -0,0 +1,27 @@
local signals = require("./signals")
local _effect = signals.effect
local onEffectCleanup = signals.onEffectCleanup
local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
local disposeImmediately = false
local function dispose()
disposeImmediately = true
end
dispose = _effect(function()
local cleanup = callback(dispose)
if cleanup then
onEffectCleanup(cleanup)
end
end)
if disposeImmediately then
dispose()
end
return dispose
end
return effect

View file

@ -0,0 +1,15 @@
local signals = require("./signals")
local _effectScope = signals.effectScope
local onScopeDispose = signals.onScopeDispose
local function effectScope(callback: () -> ...() -> ()?): () -> ()
return _effectScope(function()
local cleanup = callback()
if cleanup then
onScopeDispose(cleanup)
end
end)
end
return effectScope

View file

@ -15,6 +15,8 @@ export type Selector<T = any> = types.Selector<T>
local computed = signals.computed
local effectScope = signals.effectScope
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
return {
atom = atom,
@ -27,5 +29,7 @@ return {
observe = observe,
subscribe = subscribe,
untracked = untracked,
onEffectCleanup = onEffectCleanup,
onScopeDispose = onScopeDispose,
signals = signals,
}

View file

@ -6,40 +6,31 @@ local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
local disposed = false
local mounted = true
local disposeImmediately = false
local current: T?
local dispose: () -> ()
local function disposeSafe()
disposed = true
if dispose then
dispose()
end
local function dispose()
disposeImmediately = true
end
dispose = effect(function()
local value = selector()
if mounted then
mounted = false
elseif value == current then
return
if value ~= current then
local previous = current
current = value
pauseTracking()
spawn(listener, value, previous, dispose)
resumeTracking()
end
local previous = current
current = value
pauseTracking()
spawn(listener, value, previous, disposeSafe)
resumeTracking()
end)
if disposed then
if disposeImmediately then
dispose()
end
return disposeSafe
return dispose
end
return listen

View file

@ -19,7 +19,7 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, transform: (
resumeTracking()
if not success then
error(`observe() transform function failed: {valueOut}`)
error(`mapped() transform function failed: {valueOut}`)
end
return valueOut, keyOut
@ -47,6 +47,10 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, transform: (
return mappedItems
end
if previousItems == currentItems then
return previousMappedItems
end
local mappedItems = table.clone(previousMappedItems)
-- Update and remove mapped keys if the source values changed

View file

@ -0,0 +1,60 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local getCurrentSub = signals.getCurrentSub
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local function noop() end
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local tracked = computed(function()
return selector()
end)
local cleanups: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {}
local function update(currentItems: { [K]: V }, previousItems: { [K]: V })
for key in previousItems do
local cleanup = cleanups[key]
if currentItems[key] == nil and cleanup then
cleanups[key] = nil
cleanup()
end
end
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key) or noop
end
end
end
local function teardown()
for _, cleanup in cleanups do
cleanup()
end
table.clear(cleanups)
end
local disposeEffect = effect(function()
local previousItems = currentItems
currentItems = tracked()
update(currentItems, previousItems)
end)
if getCurrentSub() then
onEffectCleanup(teardown)
else
onScopeDispose(teardown)
end
return function()
disposeEffect()
teardown()
end
end
return observe

View file

@ -1,5 +1,5 @@
--[=[
alien-signals 2.0.1 implementation with effect cleanups, adopted from
alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals
@ -47,7 +47,7 @@ type EffectScope = ReactiveNode & {
}
type Effect = ReactiveNode & {
fn: () -> (() -> ())?,
fn: () -> (),
cleanup: (() -> ())?,
}
@ -100,26 +100,36 @@ local function setCurrentScope(scope: EffectScope?)
return prevScope
end
local function pauseTracking()
pauseStackCount += 1
pauseStack[pauseStackCount] = activeSub
activeSub = nil
local function addCleanup(e: EffectScope, cleanup: () -> ())
local prevCleanup = e.cleanup
if prevCleanup then
e.cleanup = function()
prevCleanup()
cleanup()
end
else
e.cleanup = cleanup
end
end
local function resumeTracking()
activeSub = pauseStack[pauseStackCount]
pauseStack[pauseStackCount] = nil
pauseStackCount -= 1
local function onEffectCleanup(cleanup: () -> ())
if activeSub then
addCleanup(activeSub :: Effect, cleanup)
end
end
local function cleanup(e: ReactiveNode)
local e = e :: EffectScope
local cleanup = e.cleanup
if cleanup then
e.cleanup = nil
local function onScopeDispose(cleanup: () -> ())
if activeScope then
addCleanup(activeScope, cleanup)
end
end
local function cleanupEffect(e: EffectScope)
if e.cleanup then
local prev = setCurrentSub(nil)
local success, result: unknown = pcall(cleanup)
local success, result: unknown = pcall(e.cleanup)
setCurrentSub(prev)
e.cleanup = nil
if not success then
error(result)
end
@ -128,16 +138,15 @@ end
local function run(e: EffectScope, flags: ReactiveFlags): ()
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then
cleanup(e)
cleanupEffect(e)
local prev = setCurrentSub(e)
startTracking(e)
local success, result = pcall((e :: Effect).fn)
local success, result: unknown = pcall((e :: Effect).fn)
setCurrentSub(prev)
endTracking(e)
if not success then
error(result)
end
e.cleanup = result
return
elseif bit32.band(flags, PENDING) ~= 0 then
e.flags = bit32.band(flags, bit32.bnot(PENDING))
@ -182,6 +191,18 @@ local function endBatch()
end
end
local function pauseTracking()
pauseStackCount += 1
pauseStack[pauseStackCount] = activeSub
activeSub = nil
end
local function resumeTracking()
activeSub = pauseStack[pauseStackCount]
pauseStack[pauseStackCount] = nil
pauseStackCount -= 1
end
local function updateComputed(c: Computed): boolean
local prevSub = setCurrentSub(c)
startTracking(c)
@ -210,22 +231,8 @@ local function updateSignal(s: Signal, value: any): boolean
end
end
local function signalOper<T>(this: Signal<T>, ...: T): T?
if select("#", ...) > 0 then
local newValue = (...)
if this.value ~= newValue then
this.value = newValue
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
end
end
return
else
local function signalGetterSetter<T>(this: Signal<T>): (() -> T, (value: ((T) -> T) | T) -> T)
local function getter(): T
local value = this.value
if bit32.band(this.flags, DIRTY) ~= 0 then
if updateSignal(this, value) then
@ -240,64 +247,93 @@ local function signalOper<T>(this: Signal<T>, ...: T): T?
end
return value
end
end
local function computedOper<T>(this: Computed<T>): T
local flags = this.flags
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) then
if updateComputed(this) then
local function setter(newValue: ((T) -> T) | T): T
local value = this.value
local newValue = newValue :: T
if type(newValue) == "function" then
local prev = setCurrentSub(nil)
local success, result = pcall(newValue, value)
setCurrentSub(prev)
if not success then
error(result)
end
newValue = result
end
if value ~= newValue then
this.value = newValue
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
shallowPropagate(subs)
propagate(subs)
if batchDepth == 0 then
flush()
end
end
end
elseif bit32.band(flags, PENDING) ~= 0 then
this.flags = bit32.band(flags, bit32.bnot(PENDING))
return newValue
end
if activeSub then
link(this, activeSub)
elseif activeScope then
link(this, activeScope)
end
return this.value :: T
return getter, setter
end
local function effectOper(this: EffectScope): ()
local dep = this.deps
while dep do
dep = unlink(dep, this)
local function computedGetter<T>(this: Computed<T>): () -> T
return function()
local flags = this.flags
if
bit32.band(flags, DIRTY) ~= 0
or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this))
then
if updateComputed(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif bit32.band(flags, PENDING) ~= 0 then
this.flags = bit32.band(flags, bit32.bnot(PENDING))
end
if activeSub then
link(this, activeSub)
elseif activeScope then
link(this, activeScope)
end
return this.value :: T
end
local sub = this.subs
while sub do
unlink(sub)
sub = this.subs
end
this.flags = NONE
cleanup(this)
end
local signalMetatable = { __call = signalOper }
local computedMetatable = { __call = computedOper }
local effectMetatable = { __call = effectOper }
local function effectStop(this: EffectScope): () -> ()
return function()
local dep = this.deps
while dep do
dep = unlink(dep, this)
end
local sub = this.subs
while sub do
unlink(sub)
sub = this.subs
end
this.flags = NONE
cleanupEffect(this)
end
end
local function signal<T>(initialValue: T): ((T) -> ()) & (() -> T)
local this: Signal<T> = {
local function signal<T>(initialValue: T): (() -> T, (value: ((T) -> T) | T) -> T)
return signalGetterSetter({
previousValue = initialValue,
value = initialValue,
flags = MUTABLE,
}
return setmetatable(this, signalMetatable) :: any
})
end
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
local this: Computed<T> = {
return computedGetter({
flags = bit32.bor(MUTABLE, DIRTY),
getter = getter,
}
return setmetatable(this, computedMetatable) :: any
})
end
local function effect(fn: () -> ...() -> ()?): () -> ()
local function effect(fn: () -> ()): () -> ()
local e: Effect = {
fn = fn,
flags = WATCHING,
@ -308,16 +344,15 @@ local function effect(fn: () -> ...() -> ()?): () -> ()
link(e, activeScope)
end
local prev = setCurrentSub(e)
local success, result = pcall(fn)
local success, result: unknown = pcall(fn)
setCurrentSub(prev)
if not success then
error(result)
end
e.cleanup = result
return setmetatable(e, effectMetatable) :: any
return effectStop(e)
end
local function effectScope(fn: () -> ...() -> ()?): () -> ()
local function effectScope(fn: () -> ()): () -> ()
local e: EffectScope = {
flags = NONE,
}
@ -325,13 +360,12 @@ local function effectScope(fn: () -> ...() -> ()?): () -> ()
link(e, activeScope)
end
local prev = setCurrentScope(e)
local success, result = pcall(fn)
local success, result: unknown = pcall(fn)
setCurrentScope(prev)
if not success then
error(result)
end
e.cleanup = result
return setmetatable(e, effectMetatable) :: any
return effectStop(e)
end
local function notify(e: ReactiveNode)
@ -343,7 +377,7 @@ local function notify(e: ReactiveNode)
notify(subs.sub :: Effect | EffectScope)
else
queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = e :: Effect | EffectScope
queuedEffects[queuedEffectsLength] = e :: Effect
end
end
end
@ -364,7 +398,7 @@ local function unwatched(signal: ReactiveNode)
until not toRemove
signal.flags = bit32.bor(signal.flags, DIRTY)
end
cleanup(signal)
cleanupEffect(signal :: EffectScope)
end
local reactiveSystem = createReactiveSystem({
@ -393,6 +427,8 @@ return {
endBatch = endBatch,
pauseTracking = pauseTracking,
resumeTracking = resumeTracking,
onEffectCleanup = onEffectCleanup,
onScopeDispose = onScopeDispose,
signal = signal,
computed = computed,
effect = effect,

View file

@ -8,9 +8,8 @@ local resumeTracking = signals.resumeTracking
local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
local current: T
local mounted = true
local dispose: () -> ()
dispose = effect(function()
return effect(function()
local value = selector()
if mounted then
@ -25,10 +24,6 @@ local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
resumeTracking()
end
end)
return function()
dispose()
end
end
return subscribe

View file

@ -0,0 +1,15 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
spec("useAtom")
return {}

View file

@ -3,7 +3,7 @@ local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local renderHook = require(ServerScriptService.Tests.utils.renderHook)
local renderHook = require(script.Parent.helpers.renderHook)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom

View file

@ -0,0 +1,15 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
local describe = suite.describe
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
spec("useAtom")
return {}

View file

@ -1,39 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local signals = Charm.signals
local function propagate(
signal: <T>(T) -> (T?) -> T,
computed: <T>(() -> T) -> () -> T,
effect: (() -> ()) -> (),
w: number,
h: number
)
local src = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end
return {
Functions = {
charm = function()
propagate(Charm.atom, Charm.computed, Charm.effect, 10, 10)
end,
alien_signals = function()
propagate(signals.signal, signals.computed, signals.effect, 10, 10)
end,
},
}

View file

@ -1,59 +0,0 @@
local Charm = require("../charm")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local signal = Charm.signals.signal
local computedSignal = Charm.signals.computed
local effectSignal = Charm.signals.effect
local w = 10
local h = 10
local function bench(name: string, test: () -> ())
local start = os.clock()
local iter = 0
for _ = 1, 15000 do
iter += 1
test()
end
local endTime = os.clock()
local elapsed = endTime - start
print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`)
end
bench(`charm {w} * {h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end)
bench(`alien-signals {w} * {h}`, function()
local src = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computedSignal(function()
return prev() + 1
end)
end
effectSignal(function()
last()
return nil
end)
end
src(src() + 1)
end)

View file

@ -1,17 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("client")
test("server")
test("flatten")
test("patch")
test("validate")
return {}

View file

@ -1,3 +0,0 @@
return if game
then require(game:GetService("ReplicatedStorage").Packages.Charm)
else require("../packages/charm/src/lib")

14
tests/init.luau Normal file
View file

@ -0,0 +1,14 @@
local suite = require("@tests/suite")
local describe = suite.describe
local finish = suite.finish
if not table.isfrozen(_G) then
_G.__DEV__ = true
end
describe("charm", function()
require("@pkgs/charm/__tests__/spec")
end)
finish()

View file

@ -1,13 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("useAtom")
return {}

View file

@ -1,18 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("computed")
test("effect")
test("effectScope")
test("issue_48")
test("topology")
test("untrack")
return {}

View file

@ -3,19 +3,18 @@ local suite = require("./suite")
local describe = suite.describe
local finish = suite.finish
local function test(name: string)
describe(name, function()
return require(`./{name}/spec`) :: any
end)
end
_G.__DEV__ = true
test("signals")
describe("charm-sync", function()
require(script.Parent.CharmSync.spec)
end)
if game then
_G.__DEV__ = true
test("charm-sync")
test("react-charm")
test("vide-charm")
end
describe("react-charm", function()
require(script.Parent.ReactCharm.spec)
end)
describe("vide-charm", function()
require(script.Parent.VideCharm.spec)
end)
finish()

View file

@ -1,12 +0,0 @@
--[=[
Trigger a garbage collection cycle by allocating a large amount of junk memory
and then waiting for the next frame.
]=]
local function collect()
for _ = 1, 1e4 do
local _ = table.create(1e3)
end
task.wait()
end
return collect

View file

@ -1,15 +0,0 @@
--[=[
Counts the number of elements in a table.
@param object The table to count.
@return The number of elements in the table.
]=]
local function count(object: any): number
local count = 0
for _ in next, object do
count += 1
end
return count
end
return count

View file

@ -1,13 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("useAtom")
return {}