feat: update alien-signals

This commit is contained in:
littensy 2025-10-31 23:06:43 -07:00
parent 32788b19e1
commit 7a88c784fa
14 changed files with 397 additions and 343 deletions

View file

@ -1,13 +1,13 @@
local core = require("../../core") local core = require("../../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../../test/suite")
local atom = core.atom
local computed = core.computed local computed = core.computed
local effect = core.effect local effect = core.effect
local signal = core.signal
local test = suite.test local test = suite.test
test("should correctly propagate changes through computed core", function() test("should correctly propagate changes through computed core", function()
local src = atom(0) local src, setSrc = signal(0)
local c1 = computed(function() local c1 = computed(function()
return src() % 2 return src() % 2
end) end)
@ -19,15 +19,15 @@ test("should correctly propagate changes through computed core", function()
end) end)
c3() c3()
src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none c2() -- c1 -> none, c2 -> none
src(3) -- c1 -> dirty, c2 -> toCheckDirty setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value") assert(c3() == 1, "bad final value")
end) end)
test("should propagate updated source value through chained computations", function() test("should propagate updated source value through chained computations", function()
local src = atom(0) local src, setSrc = signal(0)
local a = computed(function() local a = computed(function()
return src() return src()
end) end)
@ -42,12 +42,12 @@ test("should propagate updated source value through chained computations", funct
end) end)
assert(d() == 0, "bad initial value") assert(d() == 0, "bad initial value")
src(2) setSrc(2)
assert(d() == 2, "bad updated value") assert(d() == 2, "bad updated value")
end) end)
test("should handle flags are indirectly updated during checkDirty", function() test("should handle flags are indirectly updated during checkDirty", function()
local a = atom(false) local a, setA = signal(false)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -61,29 +61,29 @@ test("should handle flags are indirectly updated during checkDirty", function()
end) end)
assert(d() == false, "bad initial value") assert(d() == false, "bad initial value")
a(true) setA(true)
assert(d() == true, "bad updated value") assert(d() == true, "bad updated value")
end) end)
test("should not update if the atom value is reverted", function() test("should not update if the signal value is reverted", function()
local times = 0 local times = 0
local src = atom(0) local src, setSrc = signal(0)
local c1 = computed(function() local c1 = computed(function()
times += 1 times += 1
return src() return src()
end) end)
c1() c1()
assert(times == 1, "computed did not run") assert(times == 1, "computed did not run")
src(1) setSrc(1)
src(0) setSrc(0)
c1() c1()
assert(times == 1, "computed ran when it should not") assert(times == 1, "computed ran when it should not")
end) end)
test("should receive previous value", function() test("should receive previous value", function()
local calls = {} local calls = {}
local src = atom(0) local src, setSrc = signal(0)
local c1 = computed(function(current): number local c1 = computed(function(current): number
local incoming = src() local incoming = src()
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
@ -93,12 +93,12 @@ test("should receive previous value", function()
table.insert(calls, c1()) table.insert(calls, c1())
end) end)
src(2) setSrc(2)
src(3) setSrc(3)
assert(c1() == 2, "should skip odd value") assert(c1() == 2, "should skip odd value")
src(4) setSrc(4)
src(5) setSrc(5)
assert(c1() == 4, "should skip odd value") assert(c1() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`) assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end) end)

View file

@ -5,16 +5,16 @@ local computed = core.computed
local effect = core.effect local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope
local endBatch = core.endBatch local endBatch = core.endBatch
local setActiveSub = core.setActiveSub
local onCleanup = core.onCleanup local onCleanup = core.onCleanup
local atom = core.atom local setActiveSub = core.setActiveSub
local signal = core.signal
local startBatch = core.startBatch local startBatch = core.startBatch
local test = suite.test local test = suite.test
test("should clear subscriptions when untracked by all subscribers", function() test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0 local bRunTimes = 0
local a = atom(1) local a, setA = signal(1)
local b = computed(function() local b = computed(function()
bRunTimes += 1 bRunTimes += 1
return a() * 2 return a() * 2
@ -24,15 +24,15 @@ test("should clear subscriptions when untracked by all subscribers", function()
end) end)
assert(bRunTimes == 1, "should run initially") assert(bRunTimes == 1, "should run initially")
a(2) setA(2)
assert(bRunTimes == 2, "should run after change") assert(bRunTimes == 2, "should run after change")
stopEffect() stopEffect()
a(3) setA(3)
assert(bRunTimes == 2, "should not run after stop") assert(bRunTimes == 2, "should not run after stop")
end) end)
test("should not run untracked inner effect", function() test("should not run untracked inner effect", function()
local a = atom(3) local a, setA = signal(3)
local b = computed(function() local b = computed(function()
return a() > 0 return a() > 0
end) end)
@ -47,14 +47,14 @@ test("should not run untracked inner effect", function()
end end
end) end)
a(2) setA(2)
a(1) setA(1)
a(0) setA(0)
end) end)
test("should run outer effect first", function() test("should run outer effect first", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(1) local b, setB = signal(1)
effect(function() effect(function()
if a() > 0 then if a() > 0 then
@ -68,13 +68,13 @@ test("should run outer effect first", function()
end) end)
startBatch() startBatch()
b(0) setB(0)
a(0) setA(0)
endBatch() endBatch()
end) end)
test("should not trigger inner effect when resolve maybe dirty", function() test("should not trigger inner effect when resolve maybe dirty", function()
local a = atom(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
return a() % 2 return a() % 2
end) end)
@ -91,68 +91,66 @@ test("should not trigger inner effect when resolve maybe dirty", function()
end) end)
end) end)
a(2) setA(2)
end) end)
test("should trigger inner effects in sequence", function() test("should notify inner effects in the same order as non-inner effects", function()
local a = atom(0) local getA, setA = signal(0)
local b = atom(0) local getB, setB = signal(0)
local c = computed(function() local c = computed(function()
return a() - b() return getA() - getB()
end)
local order1 = {}
local order2 = {}
local order3 = {}
effect(function()
table.insert(order1, "effect1")
getA()
end)
effect(function()
table.insert(order1, "effect2")
getA()
getB()
end) end)
local order: { string } = {}
effect(function() effect(function()
c() c()
effect(function() effect(function()
table.insert(order, "first inner") table.insert(order2, "effect1")
a() getA()
end) end)
effect(function() effect(function()
table.insert(order, "last inner") table.insert(order2, "effect2")
a() getA()
b() getB()
end) end)
end) end)
table.clear(order)
startBatch()
b(1)
a(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 = atom(0)
local b = atom(0)
local order: { string } = {}
effectScope(function() effectScope(function()
effect(function() effect(function()
table.insert(order, "first inner") table.insert(order3, "effect1")
a() getA()
end) end)
effect(function() effect(function()
table.insert(order, "last inner") table.insert(order3, "effect2")
a() getA()
b() getB()
end) end)
end) end)
table.clear(order) table.clear(order1)
table.clear(order2)
table.clear(order3)
startBatch() startBatch()
b(1) setB(1)
a(1) setA(1)
endBatch() endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
end) end)
test("should custom effect support batch", function() test("should custom effect support batch", function()
@ -168,13 +166,13 @@ test("should custom effect support batch", function()
end end
local logs: { string } = {} local logs: { string } = {}
local a = atom(0) local a = signal(0)
local b = atom(0) local b, setB = signal(0)
local aa = computed(function() local aa = computed(function()
table.insert(logs, "aa-0") table.insert(logs, "aa-0")
if a() == 0 then if a() == 0 then
b(1) setB(1)
end end
table.insert(logs, "aa-1") table.insert(logs, "aa-1")
return nil return nil
@ -196,8 +194,8 @@ test("should custom effect support batch", function()
end) end)
test("should duplicate subscribers do not affect the notify order", function() test("should duplicate subscribers do not affect the notify order", function()
local src1 = atom(0) local src1, setSrc1 = signal(0)
local src2 = atom(0) local src2, setSrc2 = signal(0)
local order: { string } = {} local order: { string } = {}
effect(function() effect(function()
@ -215,17 +213,17 @@ test("should duplicate subscribers do not affect the notify order", function()
table.insert(order, "b") table.insert(order, "b")
src1() src1()
end) end)
src2(1) -- src1.subs: a -> b -> a setSrc2(1) -- src1.subs: a -> b -> a
table.clear(order) table.clear(order)
src1(src1() + 1) setSrc1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end) end)
test("should handle side effect with inner effects", function() test("should handle side effect with inner effects", function()
local a = atom(0) local a, setA = signal(0)
local b = atom(0) local b, setB = signal(0)
local order: { string } = {} local order: { string } = {}
effect(function() effect(function()
@ -240,14 +238,14 @@ test("should handle side effect with inner effects", function()
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
table.clear(order) table.clear(order)
b(1) setB(1)
a(1) setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end) end)
end) end)
test("should handle flags are indirectly updated during checkDirty", function() test("should handle flags are indirectly updated during checkDirty", function()
local a = atom(false) local a, setA = signal(false)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -267,14 +265,14 @@ test("should handle flags are indirectly updated during checkDirty", function()
triggers += 1 triggers += 1
end) end)
assert(triggers == 1, "did not run initially") assert(triggers == 1, "did not run initially")
a(true) setA(true)
assert(triggers == 2, "did not update indirectly") assert(triggers == 2, "did not update indirectly")
end) end)
test("should run cleanups in sequence", function() test("should run cleanups in sequence", function()
local order = {} local order = {}
local a = atom(false) local a, setA = signal(false)
local stopEffect = effect(function() local stopEffect = effect(function()
a() a()
onCleanup(function() onCleanup(function()
@ -287,7 +285,7 @@ test("should run cleanups in sequence", function()
assert(table.concat(order) == "", "should not cleanup immediately") assert(table.concat(order) == "", "should not cleanup immediately")
a(true) setA(true)
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`) assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
table.clear(order) table.clear(order)
@ -295,14 +293,14 @@ test("should run cleanups in sequence", function()
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`) assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
table.clear(order) table.clear(order)
a(false) setA(false)
assert(table.concat(order) == "", "should not cleanup after update") assert(table.concat(order) == "", "should not cleanup after update")
end) end)
test("should run inner effect cleanup", function() test("should run inner effect cleanup", function()
local cleanups = 0 local cleanups = 0
local src = atom(false) local src, setSrc = signal(false)
local stopEffect = effect(function() local stopEffect = effect(function()
src() src()
effect(function() effect(function()
@ -318,18 +316,18 @@ test("should run inner effect cleanup", function()
end) end)
assert(cleanups == 0, "should not cleanup immediately") assert(cleanups == 0, "should not cleanup immediately")
src(true) setSrc(true)
assert(cleanups == 2, "should cleanup after outer update") assert(cleanups == 2, "should cleanup after outer update")
stopEffect() stopEffect()
assert(cleanups == 4, "should cleanup after outer dispose") assert(cleanups == 4, "should cleanup after outer dispose")
src(false) setSrc(false)
assert(cleanups == 4, "should not cleanup after update") assert(cleanups == 4, "should not cleanup after update")
end) end)
test("should run cleanups outside any evaluation context", function() test("should run cleanups outside any evaluation context", function()
local calls = 0 local calls = 0
local a = atom(0) local a, setA = signal(0)
local b = atom(0) local b, setB = signal(0)
local c = computed(function() local c = computed(function()
if a() == 0 then if a() == 0 then
effect(function() effect(function()
@ -348,18 +346,18 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 1, "should run once") assert(calls == 1, "should run once")
calls = 0 calls = 0
a(1) setA(1)
assert(calls == 1, "should run once after a update") assert(calls == 1, "should run once after a update")
calls = 0 calls = 0
b(1) setB(1)
assert(calls == 0, "should not run after b update") assert(calls == 0, "should not run after b update")
end) end)
test("should allow self cleanup", function() test("should allow self cleanup", function()
local calls = 0 local calls = 0
local cleanups = 0 local cleanups = 0
local a = atom(0) local a, setA = signal(0)
effect(function(stopEffect) effect(function(stopEffect)
onCleanup(function() onCleanup(function()
@ -372,7 +370,7 @@ test("should allow self cleanup", function()
assert(calls == 1, "should call effect once") assert(calls == 1, "should call effect once")
assert(cleanups == 1, "should call cleanup once") assert(cleanups == 1, "should call cleanup once")
a(1) setA(1)
assert(calls == 1, "should not call effect after a update") assert(calls == 1, "should not call effect after a update")
assert(cleanups == 1, "should not call cleanup after a update") assert(cleanups == 1, "should not call cleanup after a update")
end) end)

View file

@ -3,12 +3,12 @@ local suite = require("../../../../../test/suite")
local effect = core.effect local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope
local atom = core.atom local signal = core.signal
local onCleanup = core.onCleanup local onCleanup = core.onCleanup
local test = suite.test local test = suite.test
test("should not trigger after stop", function() test("should not trigger after stop", function()
local count = atom(1) local count, setCount = signal(1)
local triggers = 0 local triggers = 0
@ -19,14 +19,14 @@ test("should not trigger after stop", function()
end) end)
assert(triggers == 1, "did not run initially") assert(triggers == 1, "did not run initially")
count(2) setCount(2)
assert(triggers == 2, "did not run after update") assert(triggers == 2, "did not run after update")
end) end)
count(3) setCount(3)
assert(triggers == 3, "did not run after update outside scope") assert(triggers == 3, "did not run after update outside scope")
stopScope() stopScope()
count(4) setCount(4)
assert(triggers == 3, "ran after stop") assert(triggers == 3, "ran after stop")
end) end)
@ -52,7 +52,7 @@ end)
test("should run indirect effect cleanup", function() test("should run indirect effect cleanup", function()
local cleanups = 0 local cleanups = 0
local a = atom(false) local a, setA = signal(false)
local stopScope = effectScope(function() local stopScope = effectScope(function()
effect(function() effect(function()
a() a()
@ -63,11 +63,11 @@ test("should run indirect effect cleanup", function()
end) end)
assert(cleanups == 0, "should not cleanup immediately") assert(cleanups == 0, "should not cleanup immediately")
a(true) setA(true)
assert(cleanups == 1, "should cleanup once") assert(cleanups == 1, "should cleanup once")
stopScope() stopScope()
assert(cleanups == 2, "should cleanup after outer dispose") assert(cleanups == 2, "should cleanup after outer dispose")
a(false) setA(false)
assert(cleanups == 2, "should not cleanup after update") assert(cleanups == 2, "should not cleanup after update")
end) end)

View file

@ -3,13 +3,13 @@ local suite = require("../../../../../test/suite")
local computed = core.computed local computed = core.computed
local effect = core.effect local effect = core.effect
local atom = core.atom local signal = core.signal
local describe = suite.describe local describe = suite.describe
local test = suite.test local test = suite.test
--[[ --[[
Tests adopted with thanks from preact-core implementation at Tests adopted with thanks from preact-core implementation at
https://github.com/preactjs/core/blob/main/packages/core/test/atom.test.tsx https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
The MIT License (MIT) The MIT License (MIT)
@ -43,7 +43,7 @@ describe("graph updates", function()
-- C -- C
-- | -- |
-- D -- D
local a = atom(2) local a, setA = signal(2)
local b = computed(function() local b = computed(function()
return a() - 1 return a() - 1
@ -64,12 +64,12 @@ describe("graph updates", function()
assert(computeCalls == 1, "compute should be called once") assert(computeCalls == 1, "compute should be called once")
computeCalls = 0 computeCalls = 0
a(4) setA(4)
d() d()
assert(computeCalls == 1, "compute should drop update") assert(computeCalls == 1, "compute should drop update")
end) end)
test("should only update every atom once (diamond graph)", function() test("should only update every signal once (diamond graph)", function()
-- In this scenario "D" should only update once when "A" receives -- In this scenario "D" should only update once when "A" receives
-- an update. This is sometimes referred to as the "diamond" scenario. -- an update. This is sometimes referred to as the "diamond" scenario.
-- A -- A
@ -77,8 +77,7 @@ describe("graph updates", function()
-- B C -- B C
-- \ / -- \ /
-- D -- D
local a, setA = signal("a")
local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -96,12 +95,12 @@ describe("graph updates", function()
assert(d() == "a a", "d should be 'a a'") assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once") assert(spyCalls == 1, "compute should be called once")
a("aa") setA("aa")
assert(d() == "aa aa", "d should be 'aa aa'") assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice") assert(spyCalls == 2, "compute should be called only twice")
end) end)
test("should only update every atom once (diamond graph + tail)", function() test("should only update every signal once (diamond graph + tail)", function()
-- "E" will be likely updated twice if our mark+sweep logic is buggy. -- "E" will be likely updated twice if our mark+sweep logic is buggy.
-- A -- A
-- / \ -- / \
@ -110,8 +109,7 @@ describe("graph updates", function()
-- D -- D
-- | -- |
-- E -- E
local a, setA = signal("a")
local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -133,7 +131,7 @@ describe("graph updates", function()
assert(e() == "a a", "e should be 'a a'") assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once") assert(spyCalls == 1, "compute should be called once")
a("aa") setA("aa")
assert(e() == "aa aa", "e should be 'aa aa'") assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice") assert(spyCalls == 2, "compute should be called only twice")
end) end)
@ -141,7 +139,7 @@ describe("graph updates", function()
test("should bail out if result is the same", function() test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes -- Bail out if value of "B" never changes
-- A->B->C -- A->B->C
local a = atom("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
a() a()
return "foo" return "foo"
@ -157,12 +155,12 @@ describe("graph updates", function()
assert(c() == "foo", "c should be 'foo'") assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once") assert(spyCalls == 1, "compute should be called once")
a("aa") setA("aa")
assert(c() == "foo", "c should be 'foo'") assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out") assert(spyCalls == 1, "compute should bail out")
end) end)
test("should only update every atom once (jagged diamond graph + tails)", function() test("should only update every signal once (jagged diamond graph + tails)", function()
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy. -- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
-- A -- A
-- / \ -- / \
@ -173,7 +171,7 @@ describe("graph updates", function()
-- E -- E
-- / \ -- / \
-- F G -- F G
local a = atom("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
@ -222,7 +220,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0 fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0
a("b") setA("b")
assert(e() == "b b", "e should be 'b b'") assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update") assert(eSpyCalls == 1, "e should run once after update")
@ -237,7 +235,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0 fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0
a("c") setA("c")
assert(e() == "c c", "e should be 'c c'") assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update") assert(eSpyCalls == 1, "e should run once after update")
@ -258,7 +256,7 @@ describe("graph updates", function()
-- *A -- *A
-- / \ -- / \
-- *B C <- we don't listen to C -- *B C <- we don't listen to C
local a = atom("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
@ -273,7 +271,7 @@ describe("graph updates", function()
assert(b() == "a", "b should be 'a'") assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run") assert(spyCalls == 0, "compute should not run")
a("aa") setA("aa")
assert(b() == "aa", "b should be 'aa'") assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run") assert(spyCalls == 0, "compute should not run")
end) end)
@ -287,7 +285,7 @@ describe("graph updates", function()
-- *B D <- we don't listen to C -- *B D <- we don't listen to C
-- | -- |
-- *C -- *C
local a = atom("a") local a, setA = signal("a")
local spyBCalls = 0 local spyBCalls = 0
local spyB = function() local spyB = function()
spyBCalls += 1 spyBCalls += 1
@ -318,7 +316,7 @@ describe("graph updates", function()
spyCCalls = 0 spyCCalls = 0
unsub() unsub()
a("aa") setA("aa")
assert(spyBCalls == 0, "b should not run") assert(spyBCalls == 0, "b should not run")
assert(spyCCalls == 0, "c should not run") assert(spyCCalls == 0, "c should not run")
@ -335,7 +333,7 @@ describe("graph updates", function()
-- B *C <- returns same value every time -- B *C <- returns same value every time
-- \ / -- \ /
-- D -- D
local a = atom("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -353,7 +351,7 @@ describe("graph updates", function()
assert(d() == "a c", "d should be 'a c'") assert(d() == "a c", "d should be 'a c'")
spyResult = nil spyResult = nil
a("aa") setA("aa")
d() d()
assert(spyResult == "aa c", "d should have returned 'aa c'") assert(spyResult == "aa c", "d should have returned 'aa c'")
end) end)
@ -367,7 +365,7 @@ describe("graph updates", function()
-- B *C *D -- B *C *D
-- \ | / -- \ | /
-- E -- E
local a = atom("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -389,13 +387,13 @@ describe("graph updates", function()
assert(e() == "a c d", "e should be 'a c d'") assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil spyResult = nil
a("aa") setA("aa")
e() e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'") assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end) end)
test("should support lazy branches", function() test("should support lazy branches", function()
local a = atom(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -404,10 +402,10 @@ describe("graph updates", function()
end) end)
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
a(1) setA(1)
assert(c() == 1, "c should be 1") assert(c() == 1, "c should be 1")
a(0) setA(0)
assert(c() == 0, "c should be 0 again") assert(c() == 0, "c should be 0 again")
end) end)
@ -419,7 +417,7 @@ describe("graph updates", function()
-- *B *C -- *B *C
-- \ / -- \ /
-- D -- D
local a = atom("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
a() a()
return "b" return "b"
@ -438,14 +436,14 @@ describe("graph updates", function()
assert(d() == "b c", "d should be 'b c'") assert(d() == "b c", "d should be 'b c'")
spyCalls = 0 spyCalls = 0
a("aa") setA("aa")
assert(spyCalls == 0, "d should not run") assert(spyCalls == 0, "d should not run")
end) end)
end) end)
describe("error handling", function() describe("error handling", function()
test("should keep graph consistent on errors during activation", function() test("should keep graph consistent on errors during activation", function()
local a = atom(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
error("fail") error("fail")
end) end)
@ -459,12 +457,12 @@ describe("error handling", function()
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(result, "fail"), "should throw fail")
a(1) setA(1)
assert(c() == 1, "c should recover and return 1") assert(c() == 1, "c should recover and return 1")
end) end)
test("should keep graph consistent on errors in computeds", function() test("should keep graph consistent on errors in computeds", function()
local a = atom(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
if a() == 1 then if a() == 1 then
error("fail") error("fail")
@ -477,14 +475,14 @@ describe("error handling", function()
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
a(1) setA(1)
local success, result = pcall(function() local success, result = pcall(function()
b() b()
end) end)
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(result, "fail"), "should throw fail")
a(2) setA(2)
assert(c() == 2, "c should recover and return 2") assert(c() == 2, "c should recover and return 2")
end) end)
end) end)

View file

@ -1,14 +1,14 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local signal = Charm.signal
local listen = Charm.listen local listen = Charm.listen
local effect = Charm.effect local effect = Charm.effect
local onCleanup = Charm.onCleanup local onCleanup = Charm.onCleanup
local test = suite.test local test = suite.test
test("should listen to selector", function() test("should listen to selector", function()
local source = atom(0) local source, setSource = signal(0)
local current, previous local current, previous
local unsub = listen(function() local unsub = listen(function()
@ -19,20 +19,20 @@ test("should listen to selector", function()
end) end)
assert(current == 1 and previous == nil, "should run initially") assert(current == 1 and previous == nil, "should run initially")
source(0) setSource(0)
assert(current == 1 and previous == nil, "should skip redundant update") assert(current == 1 and previous == nil, "should skip redundant update")
source(1) setSource(1)
assert(current == 2 and previous == 1, "should run with new value") assert(current == 2 and previous == 1, "should run with new value")
unsub() unsub()
source(2) setSource(2)
assert(current == 2 and previous == 1, "should not run after unsub") assert(current == 2 and previous == 1, "should not run after unsub")
end) end)
test("should run in peek", function() test("should run in peek", function()
local runs = 0 local runs = 0
local cleanups = 0 local cleanups = 0
local a = atom(0) local a, setA = signal(0)
local b = atom(0) local b, setB = signal(0)
listen(a, function() listen(a, function()
runs += 1 runs += 1
@ -47,31 +47,31 @@ test("should run in peek", function()
assert(runs == 1, "should run once on init") assert(runs == 1, "should run once on init")
assert(cleanups == 0, "should not cleanup yet") assert(cleanups == 0, "should not cleanup yet")
a(1) setA(1)
assert(runs == 2, "should run on a change") assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call") assert(cleanups == 1, "should cleanup first call")
b(1) setB(1)
assert(runs == 2, "should not run on b change") assert(runs == 2, "should not run on b change")
assert(cleanups == 1, "should not cleanup second call") assert(cleanups == 1, "should not cleanup second call")
end) end)
test("should run if first value is nil", function() test("should run if first value is nil", function()
local runs = 0 local runs = 0
local source = atom(nil :: number?) local source, setSource = signal(nil :: number?)
listen(source, function() listen(source, function()
runs += 1 runs += 1
end) end)
assert(runs == 1, "should run once on init") assert(runs == 1, "should run once on init")
source(1) setSource(1)
assert(runs == 2, "should run once on change") assert(runs == 2, "should run once on change")
end) end)
test("should track inner subs", function() test("should track inner subs", function()
local order = {} local order = {}
local a = atom(1) local a, setA = signal(1)
local unsub = listen(a, function() local unsub = listen(a, function()
table.insert(order, 1) table.insert(order, 1)
@ -86,22 +86,22 @@ test("should track inner subs", function()
assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad mount order: {table.concat(order)}`)
table.clear(order) table.clear(order)
a(a() + 1) setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order) table.clear(order)
a(a() + 1) setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order) table.clear(order)
unsub() unsub()
a(a() + 1) setA(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`) assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end) end)
test("should skip redundant updates", function() test("should skip redundant updates", function()
local order = {} local order = {}
local a = atom(0) local a, setA = signal(0)
listen(function(): "even" | "odd" listen(function(): "even" | "odd"
return if a() % 2 == 0 then "even" else "odd" return if a() % 2 == 0 then "even" else "odd"
@ -109,17 +109,17 @@ test("should skip redundant updates", function()
table.insert(order, value) table.insert(order, value)
end) end)
a(1) setA(1)
a(3) setA(3)
a(4) setA(4)
a(6) setA(6)
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
end) end)
test("should allow self cleanup", function() test("should allow self cleanup", function()
local calls = 0 local calls = 0
local cleanups = 0 local cleanups = 0
local a = atom(0) local a, setA = signal(0)
listen(a, function(_, _, stopEffect) listen(a, function(_, _, stopEffect)
onCleanup(function() onCleanup(function()
@ -131,7 +131,7 @@ test("should allow self cleanup", function()
assert(calls == 1, "should call effect once") assert(calls == 1, "should call effect once")
assert(cleanups == 1, "should call cleanup once") assert(cleanups == 1, "should call cleanup once")
a(1) setA(1)
assert(calls == 1, "should not call effect after a update") assert(calls == 1, "should not call effect after a update")
assert(cleanups == 1, "should not call cleanup after a update") assert(cleanups == 1, "should not call cleanup after a update")
end) end)

View file

@ -1,25 +1,25 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local signal = Charm.signal
local mapped = Charm.mapped local mapped = Charm.mapped
local test = suite.test local test = suite.test
test("should map atom", function() test("should map state", function()
local src = atom({ 1, 2, 3 }) local src, setSrc = signal({ 1, 2, 3 })
local map = mapped(src, function(value: number) local map = mapped(src, function(value: number)
return value + 1 return value + 1
end) end)
assert(table.concat(map()) == "234", table.concat(map())) assert(table.concat(map()) == "234", table.concat(map()))
src({ 4, 5, 6 }) setSrc({ 4, 5, 6 })
assert(table.concat(map()) == "567", table.concat(map())) assert(table.concat(map()) == "567", table.concat(map()))
end) end)
test("should not map unchanged values", function() test("should not map unchanged values", function()
local calls = 0 local calls = 0
local src = atom({ 1, 2, 3 }) local src, setSrc = signal({ 1, 2, 3 })
local map = mapped(src, function(value: number) local map = mapped(src, function(value: number)
calls += 1 calls += 1
return value + 1 return value + 1
@ -28,11 +28,11 @@ test("should not map unchanged values", function()
assert(table.concat(map()) == "234", table.concat(map())) assert(table.concat(map()) == "234", table.concat(map()))
assert(calls == 3, "should map 3 values") assert(calls == 3, "should map 3 values")
src({ 1, 3, 3 }) setSrc({ 1, 3, 3 })
assert(table.concat(map()) == "244", table.concat(map())) assert(table.concat(map()) == "244", table.concat(map()))
assert(calls == 4, "should map 1 change") assert(calls == 4, "should map 1 change")
src({}) setSrc({})
assert(#map() == 0, "should be empty") assert(#map() == 0, "should be empty")
assert(calls == 4, "should not map for removed values") assert(calls == 4, "should not map for removed values")
end) end)
@ -40,7 +40,7 @@ end)
test("should not update if no values change", function() test("should not update if no values change", function()
local calls = 0 local calls = 0
local src = atom({ 1, 2, 3 }) local src, setSrc = signal({ 1, 2, 3 })
local map = mapped(src, function(value: number) local map = mapped(src, function(value: number)
calls += 1 calls += 1
return value + 1 return value + 1
@ -50,8 +50,8 @@ test("should not update if no values change", function()
assert(calls == 3, "should map 3 values") assert(calls == 3, "should map 3 values")
assert(table.concat(map()) == "234", table.concat(map())) assert(table.concat(map()) == "234", table.concat(map()))
src({ 4, 5, 6, 7 }) setSrc({ 4, 5, 6, 7 })
src({ 1, 2, 3 }) setSrc({ 1, 2, 3 })
map() map()
assert(calls == 3, "should not map unchanged values") assert(calls == 3, "should not map unchanged values")
assert(table.concat(map()) == "234", table.concat(map())) assert(table.concat(map()) == "234", table.concat(map()))
@ -61,7 +61,7 @@ end)
test("should filter values", function() test("should filter values", function()
local calls = 0 local calls = 0
local src = atom({ a = 1, b = 2, c = 3 }) local src, setSrc = signal({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number) local map = mapped(src, function(value: number)
calls += 1 calls += 1
return if value < 0 then nil else value return if value < 0 then nil else value
@ -72,7 +72,7 @@ test("should filter values", function()
assert(state.a and state.b and state.c, "should map all values") assert(state.a and state.b and state.c, "should map all values")
calls = 0 calls = 0
src({ a = -1, b = 2, c = 3, d = 4 }) setSrc({ a = -1, b = 2, c = 3, d = 4 })
state = map() state = map()
assert(calls == 2, "should map 2 values") assert(calls == 2, "should map 2 values")
assert(state.a == nil, "should remove a") assert(state.a == nil, "should remove a")
@ -83,7 +83,7 @@ end)
test("should map keys", function() test("should map keys", function()
local calls = 0 local calls = 0
local src = atom({ a = 1, b = 2, c = 3 }) local src, setSrc = signal({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number, key: string) local map = mapped(src, function(value: number, key: string)
calls += 1 calls += 1
return if value < 0 then nil else value, string.upper(key) return if value < 0 then nil else value, string.upper(key)
@ -97,7 +97,7 @@ test("should map keys", function()
assert(state.C == 3, "should map c") assert(state.C == 3, "should map c")
calls = 0 calls = 0
src({ a = -1, b = 2, c = 3, d = 4 }) setSrc({ a = -1, b = 2, c = 3, d = 4 })
state = map() state = map()
assert(calls == 2, "should map 2 values") assert(calls == 2, "should map 2 values")
assert(state.A == nil and state.a == nil, "should remove a") assert(state.A == nil and state.a == nil, "should remove a")

View file

@ -1,14 +1,14 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local observe = Charm.observe local observe = Charm.observe
local test = suite.test local test = suite.test
test("should observe items", function() test("should observe items", function()
local items = atom({ 0, 0, 0 }) local items, setItems = signal({ 0, 0, 0 })
local updates = {} local updates = {}
local cleanups = {} local cleanups = {}
@ -29,18 +29,18 @@ test("should observe items", function()
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`) assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
table.clear(updates) table.clear(updates)
items({ 1, 2, 3 }) setItems({ 1, 2, 3 })
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`) assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
table.clear(updates) table.clear(updates)
items({ 1, 2, nil :: any, 4 }) setItems({ 1, 2, nil :: any, 4 })
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`) assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`) assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
end) end)
test("should dispose scopes", function() test("should dispose scopes", function()
local items = atom({ 1, 2, 3 }) local items, setItems = signal({ 1, 2, 3 })
local a = atom(0) local a, setA = signal(0)
local runs = {} local runs = {}
local dispose = observe(items, function(item) local dispose = observe(items, function(item)
@ -51,23 +51,23 @@ test("should dispose scopes", function()
end) end)
table.clear(runs) table.clear(runs)
items({ 1, 2 }) setItems({ 1, 2 })
a(1) setA(1)
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
table.clear(runs) table.clear(runs)
dispose() dispose()
a(2) setA(2)
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
end) end)
test("should queue addition", function() test("should queue addition", function()
local items = atom({ 1 }) local items, setItems = signal({ 1 })
local edits = {} local edits = {}
observe(items, function(item) observe(items, function(item)
items({ 1, 2 }) setItems({ 1, 2 })
items({ 1, 2, 3, 4 }) setItems({ 1, 2, 3, 4 })
table.insert(edits, `+{item}`) table.insert(edits, `+{item}`)
return function() return function()
table.insert(edits, `-{item}`) table.insert(edits, `-{item}`)
@ -78,12 +78,12 @@ test("should queue addition", function()
end) end)
test("should queue removal", function() test("should queue removal", function()
local items = atom({ 1, 2, 3, 4 }) local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {} local edits = {}
observe(items, function(item) observe(items, function(item)
items({ 1, 2, 3 }) setItems({ 1, 2, 3 })
items({ 1 }) setItems({ 1 })
table.insert(edits, `+{item}`) table.insert(edits, `+{item}`)
return function() return function()
table.insert(edits, `-{item}`) table.insert(edits, `-{item}`)

View file

@ -1,14 +1,14 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local peek = Charm.peek local peek = Charm.peek
local test = suite.test local test = suite.test
test("should not run detached", function() test("should not run detached", function()
local a = atom(1) local a, setA = signal(1)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -23,15 +23,15 @@ test("should not run detached", function()
end) end)
assert(spyCalls == 0, "should not be called yet") assert(spyCalls == 0, "should not be called yet")
a(2) setA(2)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(3) setA(3)
assert(spyCalls == 2, "should be called twice") assert(spyCalls == 2, "should be called twice")
end) end)
test("should block tracking inside effects", function() test("should block tracking inside effects", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(2) local b, setB = signal(2)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -42,13 +42,13 @@ test("should block tracking inside effects", function()
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(10) setA(10)
b(20) setB(20)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
end) end)
test("should block tracking even when run inside effect run inside peek", function() test("should block tracking even when run inside effect run inside peek", function()
local s = atom(1) local s, setS = signal(1)
local spyCalls = 0 local spyCalls = 0
peek(function() peek(function()
@ -61,32 +61,32 @@ test("should block tracking even when run inside effect run inside peek", functi
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
s(2) setS(2)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
end) end)
test("should not cause atom assignments throw", function() test("should not cause signal assignments to throw", function()
local a = atom(1) local a, setA = signal(1)
local aChangedTime = atom(0) local aChangedTime, setAChangedTime = signal(0)
local dispose = effect(function() local dispose = effect(function()
a() a()
peek(function() peek(function()
aChangedTime(aChangedTime() + 1) setAChangedTime(aChangedTime() + 1)
end) end)
end) end)
a(2) setA(2)
assert(aChangedTime() == 2, "aChangedTime should be 2") assert(aChangedTime() == 2, "aChangedTime should be 2")
a(3) setA(3)
assert(aChangedTime() == 3, "aChangedTime should be 3") assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose() dispose()
end) end)
test("should block tracking inside computed atoms", function() test("should block tracking inside computed signals", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(2) local b, setB = signal(2)
local spyCalls = 0 local spyCalls = 0
local c = computed(function() local c = computed(function()
return peek(function() return peek(function()
@ -97,18 +97,18 @@ test("should block tracking inside computed atoms", function()
assert(spyCalls == 0, "should not be called yet") assert(spyCalls == 0, "should not be called yet")
assert(c() == 3, "c should be 3") assert(c() == 3, "c should be 3")
a(10) setA(10)
c() c()
b(20) setB(20)
c() c()
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
assert(c() == 3, "c should be 3") assert(c() == 3, "c should be 3")
end) end)
test("should track outside peek", function() test("should track outside peek", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(2) local b, setB = signal(2)
local c = atom(3) local c, setC = signal(3)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -119,16 +119,16 @@ test("should track outside peek", function()
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(10) setA(10)
assert(spyCalls == 2, "should be called twice") assert(spyCalls == 2, "should be called twice")
b(20) setB(20)
assert(spyCalls == 3, "should be called three times") assert(spyCalls == 3, "should be called three times")
c(30) setC(30)
assert(spyCalls == 3, "should not run on c change") assert(spyCalls == 3, "should not run on c change")
end) end)
test("should stack peek", function() test("should stack peek", function()
local a = atom(1) local a, setA = signal(1)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -140,7 +140,7 @@ test("should stack peek", function()
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(2) setA(2)
assert(spyCalls == 1, "should not be called twice") assert(spyCalls == 1, "should not be called twice")
end) end)

View file

@ -1,9 +1,7 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
type Atom<T> = Charm.Atom<T> local signal = Charm.signal
local atom = Charm.atom
local computed = Charm.computed local computed = Charm.computed
local subscribe = Charm.subscribe local subscribe = Charm.subscribe
local listen = Charm.listen local listen = Charm.listen
@ -11,7 +9,7 @@ local effect = Charm.effect
local test = suite.test local test = suite.test
test("should subscribe to selector", function() test("should subscribe to selector", function()
local source = atom(0) local source, setSource = signal(0)
local current, previous local current, previous
local unsub = subscribe(function() local unsub = subscribe(function()
@ -22,20 +20,20 @@ test("should subscribe to selector", function()
end) end)
assert(not current and not previous, "should not run initially") assert(not current and not previous, "should not run initially")
source(0) setSource(0)
assert(not current and not previous, "should skip redundant update") assert(not current and not previous, "should skip redundant update")
source(1) setSource(1)
assert(current == 2 and previous == 1, "should run with new value") assert(current == 2 and previous == 1, "should run with new value")
unsub() unsub()
source(2) setSource(2)
assert(current == 2 and previous == 1, "should not run after unsub") assert(current == 2 and previous == 1, "should not run after unsub")
end) end)
test("should run in peek", function() test("should run in peek", function()
local runs = 0 local runs = 0
local cleanups = 0 local cleanups = 0
local a = atom(0) local a, setA = signal(0)
local b = atom(0) local b, setB = signal(0)
subscribe(a, function() subscribe(a, function()
runs += 1 runs += 1
@ -50,30 +48,30 @@ test("should run in peek", function()
assert(runs == 0, "should not run once on init") assert(runs == 0, "should not run once on init")
assert(cleanups == 0, "should not cleanup yet") assert(cleanups == 0, "should not cleanup yet")
a(1) setA(1)
assert(runs == 1, "should run once on change") assert(runs == 1, "should run once on change")
assert(cleanups == 0, "should not cleanup yet") assert(cleanups == 0, "should not cleanup yet")
b(1) setB(1)
assert(runs == 1, "should not run on b change") assert(runs == 1, "should not run on b change")
assert(cleanups == 0, "should not cleanup first call") assert(cleanups == 0, "should not cleanup first call")
a(2) setA(2)
assert(runs == 2, "should run on a change") assert(runs == 2, "should run on a change")
assert(cleanups == 1, "should cleanup first call") assert(cleanups == 1, "should cleanup first call")
end) end)
test("should ensure correct execution order", function() test("should ensure correct execution order", function()
local order = {} local order = {}
local n1 = atom(0) local n1, setN1 = signal(0)
local n2 = atom(0) local n2, setN2 = signal(0)
local sum = computed(function() local sum = computed(function()
return n1() + n2() return n1() + n2()
end) end)
subscribe(n1, function() subscribe(n1, function()
table.insert(order, 1) table.insert(order, 1)
n2(n2() + 1) setN2(n2() + 1)
end) end)
subscribe(sum, function() subscribe(sum, function()
@ -84,14 +82,14 @@ test("should ensure correct execution order", function()
table.insert(order, 3) table.insert(order, 3)
end) end)
n1(n1() + 1) setN1(n1() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end) end)
test("should track inner subs", function() test("should track inner subs", function()
local order = {} local order = {}
local a = atom(1) local a, setA = signal(1)
local unsub = subscribe(a, function() local unsub = subscribe(a, function()
table.insert(order, 1) table.insert(order, 1)
@ -103,22 +101,22 @@ test("should track inner subs", function()
end) end)
end) end)
a(a() + 1) setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order) table.clear(order)
a(a() + 1) setA(a() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`) assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
table.clear(order) table.clear(order)
unsub() unsub()
a(a() + 1) setA(a() + 1)
assert(table.concat(order) == "", `bad order: {table.concat(order)}`) assert(table.concat(order) == "", `bad order: {table.concat(order)}`)
end) end)
test("should skip redundant updates", function() test("should skip redundant updates", function()
local order = {} local order = {}
local a = atom(0) local a, setA = signal(0)
subscribe(function(): "even" | "odd" subscribe(function(): "even" | "odd"
return if a() % 2 == 0 then "even" else "odd" return if a() % 2 == 0 then "even" else "odd"
@ -126,10 +124,10 @@ test("should skip redundant updates", function()
table.insert(order, value) table.insert(order, value)
end) end)
a(1) setA(1)
a(3) setA(3)
a(4) setA(4)
a(6) setA(6)
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`) assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
end) end)

View file

@ -1,7 +1,7 @@
local Charm = require("../") local Charm = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope local effectScope = Charm.effectScope
@ -9,7 +9,7 @@ local untracked = Charm.untracked
local test = suite.test local test = suite.test
test("should run detached from effect", function() test("should run detached from effect", function()
local a = atom(1) local a, setA = signal(1)
local effects = 0 local effects = 0
local innerEffects = {} local innerEffects = {}
@ -27,7 +27,7 @@ test("should run detached from effect", function()
end) end)
assert(effects == 1, "should have one effect") assert(effects == 1, "should have one effect")
a(2) setA(2)
assert(effects == 2, "should have two effects") assert(effects == 2, "should have two effects")
stopEffect() stopEffect()
assert(effects == 2, "should still have two effects") assert(effects == 2, "should still have two effects")
@ -60,8 +60,8 @@ test("should run detached from effect scope", function()
end) end)
test("should block tracking inside effects", function() test("should block tracking inside effects", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(2) local b, setB = signal(2)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -72,13 +72,13 @@ test("should block tracking inside effects", function()
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(10) setA(10)
b(20) setB(20)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
end) end)
test("should block tracking even when run inside effect run inside untracked", function() test("should block tracking even when run inside effect run inside untracked", function()
local s = atom(1) local s, setS = signal(1)
local spyCalls = 0 local spyCalls = 0
untracked(function() untracked(function()
@ -91,32 +91,32 @@ test("should block tracking even when run inside effect run inside untracked", f
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
s(2) setS(2)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
end) end)
test("should not cause atom assignments throw", function() test("should not cause signal assignments to throw", function()
local a = atom(1) local a, setA = signal(1)
local aChangedTime = atom(0) local aChangedTime, setAChangedTime = signal(0)
local dispose = effect(function() local dispose = effect(function()
a() a()
untracked(function() untracked(function()
aChangedTime(aChangedTime() + 1) setAChangedTime(aChangedTime() + 1)
end) end)
end) end)
a(2) setA(2)
assert(aChangedTime() == 2, "aChangedTime should be 2") assert(aChangedTime() == 2, "aChangedTime should be 2")
a(3) setA(3)
assert(aChangedTime() == 3, "aChangedTime should be 3") assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose() dispose()
end) end)
test("should block tracking inside computed atoms", function() test("should block tracking inside computed signals", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(2) local b, setB = signal(2)
local spyCalls = 0 local spyCalls = 0
local c = computed(function() local c = computed(function()
return untracked(function() return untracked(function()
@ -127,18 +127,18 @@ test("should block tracking inside computed atoms", function()
assert(spyCalls == 0, "should not be called yet") assert(spyCalls == 0, "should not be called yet")
assert(c() == 3, "c should be 3") assert(c() == 3, "c should be 3")
a(10) setA(10)
c() c()
b(20) setB(20)
c() c()
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
assert(c() == 3, "c should be 3") assert(c() == 3, "c should be 3")
end) end)
test("should track outside untracked", function() test("should track outside untracked", function()
local a = atom(1) local a, setA = signal(1)
local b = atom(2) local b, setB = signal(2)
local c = atom(3) local c, setC = signal(3)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -149,16 +149,16 @@ test("should track outside untracked", function()
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(10) setA(10)
assert(spyCalls == 2, "should be called twice") assert(spyCalls == 2, "should be called twice")
b(20) setB(20)
assert(spyCalls == 3, "should be called three times") assert(spyCalls == 3, "should be called three times")
c(30) setC(30)
assert(spyCalls == 3, "should not run on c change") assert(spyCalls == 3, "should not run on c change")
end) end)
test("should stack untracked", function() test("should stack untracked", function()
local a = atom(1) local a, setA = signal(1)
local spyCalls = 0 local spyCalls = 0
effect(function() effect(function()
@ -170,7 +170,7 @@ test("should stack untracked", function()
end) end)
assert(spyCalls == 1, "should be called once") assert(spyCalls == 1, "should be called once")
a(2) setA(2)
assert(spyCalls == 1, "should not be called twice") assert(spyCalls == 1, "should not be called twice")
end) end)

View file

@ -1,3 +1,30 @@
--[[
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 = { export type ReactiveNode = {
deps: Link?, deps: Link?,
depsTail: Link?, depsTail: Link?,
@ -23,14 +50,14 @@ type Stack<T> = {
export type ReactiveFlags = number export type ReactiveFlags = number
local NONE = 0b00000000 local NONE = 0b0000000
local MUTABLE = 0b00000001 local MUTABLE = 0b0000001
local WATCHING = 0b00000010 local WATCHING = 0b0000010
local RECURSED_CHECK = 0b00000100 local RECURSED_CHECK = 0b0000100
local RECURSED = 0b00001000 local RECURSED = 0b0001000
local DIRTY = 0b00010000 local DIRTY = 0b0010000
local PENDING = 0b00100000 local PENDING = 0b0100000
local PEEKING = 0b10000000 local PEEKING = 0b1000000
local ReactiveFlags = { local ReactiveFlags = {
None = NONE, None = NONE,

View file

@ -11,14 +11,40 @@ local createReactiveSystem = alien.createReactiveSystem
local deepFreeze = utils.deepFreeze local deepFreeze = utils.deepFreeze
local noYield = utils.noYield local noYield = utils.noYield
local NONE = 0b00000000 --[[
local MUTABLE = 0b00000001 Reactive system adapted from alien-signals at
local WATCHING = 0b00000010 https://github.com/stackblitz/alien-signals/blob/master/src/index.ts
local RECURSED_CHECK = 0b00000100
local DIRTY = 0b00010000 MIT License
local PENDING = 0b00100000
local QUEUED = 0b01000000 Copyright (c) 2024-present Johnson Chu
local PEEKING = 0b10000000
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local RECURSED_CHECK = 0b0000100
local DIRTY = 0b0010000
local PENDING = 0b0100000
local PEEKING = 0b1000000
type EffectScope = ReactiveNode & { type EffectScope = ReactiveNode & {
cleanups: { () -> () }, cleanups: { () -> () },
@ -49,8 +75,8 @@ local shallowPropagate: (link: Link) -> ()
local cycle = 0 local cycle = 0
local batchDepth = 0 local batchDepth = 0
local notifyIndex = 0 local notifyIndex = 0
local queuedEffects: { Effect | EffectScope? } = {} local queued: { Effect? } = {}
local queuedEffectsLength = 0 local queuedLength = 0
local activeSub: ReactiveNode? local activeSub: ReactiveNode?
local function getActiveSub(): ReactiveNode? local function getActiveSub(): ReactiveNode?
@ -83,15 +109,9 @@ local function runCleanups(effect: EffectScope)
table.clear(effect.cleanups) table.clear(effect.cleanups)
end end
local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) local function run(e: Effect): ()
sub.flags = bit32.band(flags, bit32.bnot(PENDING)) local flags = e.flags
end if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then
local function run(e: Effect, flags: ReactiveFlags): ()
if
bit32.btest(flags, DIRTY)
or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags)))
then
runCleanups(e) runCleanups(e)
cycle += 1 cycle += 1
e.depsTail = nil e.depsTail = nil
@ -107,30 +127,19 @@ local function run(e: Effect, flags: ReactiveFlags): ()
table.insert(e.cleanups, result) table.insert(e.cleanups, result)
end end
else else
local link = e.deps e.flags = WATCHING
while link do
local dep = link.dep
local depFlags = dep.flags
if bit32.btest(depFlags, QUEUED) then
depFlags = bit32.band(depFlags, bit32.bnot(QUEUED))
dep.flags = depFlags
run(dep :: Effect, depFlags)
end
link = link.nextDep
end
end end
end end
local function flush(): () local function flush(): ()
while notifyIndex < queuedEffectsLength do while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: Effect
queued[notifyIndex] = nil
notifyIndex += 1 notifyIndex += 1
local effect = queuedEffects[notifyIndex] :: Effect run(effect)
queuedEffects[notifyIndex] = nil
effect.flags = bit32.band(effect.flags, bit32.bnot(QUEUED))
run(effect, effect.flags)
end end
notifyIndex = 0 notifyIndex = 0
queuedEffectsLength = 0 queuedLength = 0
end end
local function getBatchDepth(): number local function getBatchDepth(): number
@ -183,6 +192,10 @@ local function updateSignal(s: Signal): boolean
end end
end end
local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags)
sub.flags = bit32.band(flags, bit32.bnot(PENDING))
end
local function computedOper<T>(this: Computed<T>): () -> T local function computedOper<T>(this: Computed<T>): () -> T
return function(): T return function(): T
local flags = this.flags local flags = this.flags
@ -196,6 +209,16 @@ local function computedOper<T>(this: Computed<T>): () -> T
shallowPropagate(subs) shallowPropagate(subs)
end end
end end
elseif flags == NONE then
this.flags = MUTABLE
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
if success then
this.value = result
else
error(result)
end
end end
if activeSub then if activeSub then
link(this, activeSub, cycle) link(this, activeSub, cycle)
@ -332,7 +355,7 @@ end
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
local function computed<T>(getter: (previousValue: T?) -> T): () -> T local function computed<T>(getter: (previousValue: T?) -> T): () -> T
return computedOper({ return computedOper({
flags = bit32.bor(MUTABLE, DIRTY), flags = NONE,
getter = getter, getter = getter,
}) })
end end
@ -536,30 +559,40 @@ local function listen<T>(getter: () -> T, callback: (state: T, previous: T?, dis
return dispose return dispose
end end
local function notify(e: ReactiveNode)
local flags = e.flags
if not bit32.btest(flags, QUEUED) then
e.flags = bit32.bor(flags, QUEUED)
local subs = e.subs
if subs then
notify(subs.sub)
else
queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = e :: Effect
end
end
end
local reactiveSystem = createReactiveSystem({ local reactiveSystem = createReactiveSystem({
update = function(node: ReactiveNode): boolean update = function(node)
if node.depsTail then if node.depsTail then
return updateComputed(node :: Computed) return updateComputed(node :: Computed)
else else
return updateSignal(node :: Signal) return updateSignal(node :: Signal)
end end
end, end,
notify = notify, notify = function(effect)
unwatched = function(node: ReactiveNode) local insertIndex = queuedLength
local firstInsertedIndex = insertIndex
repeat
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
queued[insertIndex] = effect :: Effect
insertIndex += 1
effect = (effect.subs and effect.subs.sub) :: Effect
if not effect or bit32.band(effect.flags, WATCHING) == 0 then
break
end
until false
queuedLength = insertIndex
insertIndex -= 1
while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left
firstInsertedIndex += 1
insertIndex -= 1
end
end,
unwatched = function(node)
if bit32.band(node.flags, MUTABLE) == 0 then if bit32.band(node.flags, MUTABLE) == 0 then
effectScopeOper(node :: EffectScope)() effectScopeOper(node :: EffectScope)()
elseif node.depsTail then elseif node.depsTail then

View file

@ -1,10 +1,10 @@
local function noYieldHandler(thread: thread, ok: boolean, ...) local function noYieldHandler(thread: thread, ok: boolean, ...): (boolean, ...any)
if not ok then if ok and coroutine.status(thread) == "dead" then
return false, debug.traceback(thread, tostring((...)))
elseif coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "Attempted to yield!")
else
return true, ... return true, ...
elseif not ok then
return false, debug.traceback(thread, tostring((...)))
else
return false, debug.traceback(thread, "Attempted to yield!")
end end
end end

View file

@ -5,9 +5,9 @@
[tools] [tools]
wally = "UpliftGames/wally@0.3.2" wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.5.1" rojo = "rojo-rbx/rojo@7.6.0"
stylua = "johnnymorganz/stylua@2.2.0" stylua = "johnnymorganz/stylua@2.3.0"
selene = "Kampfkarren/selene@0.29.0" selene = "Kampfkarren/selene@0.29.0"
luau-lsp = "johnnymorganz/luau-lsp@1.53.5" luau-lsp = "johnnymorganz/luau-lsp@1.55.0"
lune = "lune-org/lune@0.10.2" lune = "lune-org/lune@0.10.4"
lute = "luau-lang/lute@0.1.0-nightly.20250923" lute = "luau-lang/lute@0.1.0-nightly.20250923"