charm/packages/charm/test/effect.test.luau
2026-07-21 20:56:48 -04:00

674 lines
14 KiB
Text

local Charm = require("../src")
local test = require("../../../tests/test")
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local endBatch = Charm.endBatch
local onCleanup = Charm.onCleanup
local setActiveSub = Charm.setActiveSub
local signal = Charm.signal
local startBatch = Charm.startBatch
test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0
local a, setA = signal(1)
local b = computed(function()
bRunTimes += 1
return a() * 2
end)
local stopEffect = effect(function()
b()
end)
assert(bRunTimes == 1, "should run initially")
setA(2)
assert(bRunTimes == 2, "should run after change")
stopEffect()
setA(3)
assert(bRunTimes == 2, "should not run after stop")
end)
test("should not run untracked inner effect", function()
local a, setA = signal(3)
local b = computed(function()
return a() > 0
end)
effect(function()
if b() then
effect(function()
if a() == 0 then
error("bad")
end
end)
end
end)
setA(2)
setA(1)
setA(0)
end)
test("should run outer effect first", function()
local a, setA = signal(1)
local b, setB = signal(1)
effect(function()
if a() > 0 then
effect(function()
b()
if a() == 0 then
error("bad")
end
end)
end
end)
startBatch()
setB(0)
setA(0)
endBatch()
end)
test("should not trigger inner effect when resolve maybe dirty", function()
local a, setA = signal(0)
local b = computed(function()
return a() % 2
end)
local innerTriggerTimes = 0
effect(function()
effect(function()
b()
innerTriggerTimes += 1
if innerTriggerTimes >= 2 then
error("bad")
end
end)
end)
setA(2)
end)
test("should notify inner effects in the same order as non-inner effects", function()
local getA, setA = signal(0)
local getB, setB = signal(0)
local c = computed(function()
return getA() - getB()
end)
local order1 = {}
local order2 = {}
local order3 = {}
effect(function()
table.insert(order1, "effect1")
getA()
end)
effect(function()
table.insert(order1, "effect2")
getA()
getB()
end)
effect(function()
c()
effect(function()
table.insert(order2, "effect1")
getA()
end)
effect(function()
table.insert(order2, "effect2")
getA()
getB()
end)
end)
effectScope(function()
effect(function()
table.insert(order3, "effect1")
getA()
end)
effect(function()
table.insert(order3, "effect2")
getA()
getB()
end)
end)
table.clear(order1)
table.clear(order2)
table.clear(order3)
startBatch()
setB(1)
setA(1)
endBatch()
assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
end)
test("should custom effect support batch", function()
local function batchEffect(fn: () -> ())
return effect(function()
startBatch()
local success, result: unknown = pcall(fn)
endBatch()
if not success then
error(result)
end
end)
end
local logs: { string } = {}
local a = signal(0)
local b, setB = signal(0)
local aa = computed(function()
table.insert(logs, "aa-0")
if a() == 0 then
setB(1)
end
table.insert(logs, "aa-1")
return nil
end)
local bb = computed(function()
table.insert(logs, "bb")
return b()
end)
batchEffect(function()
bb()
end)
batchEffect(function()
aa()
end)
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
end)
test("should duplicate subscribers do not affect the notify order", function()
local src1, setSrc1 = signal(0)
local src2, setSrc2 = signal(0)
local order: { string } = {}
effect(function()
table.insert(order, "a")
local currentSub = setActiveSub(nil)
local isOne = src2() == 1
setActiveSub(currentSub)
if isOne then
src1()
end
src2()
src1()
end)
effect(function()
table.insert(order, "b")
src1()
end)
setSrc2(1) -- src1.subs: a -> b -> a
table.clear(order)
setSrc1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end)
test("should handle side effect with inner effects", function()
local a, setA = signal(0)
local b, setB = signal(0)
local order: { string } = {}
effect(function()
effect(function()
a()
table.insert(order, "a")
end)
effect(function()
b()
table.insert(order, "b")
end)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
table.clear(order)
setB(1)
setA(1)
end)
-- DEVIATION: This assertion was moved outside of the effect so that it
-- runs after the batch updates have been processed
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a, setA = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
local triggers = 0
effect(function()
d()
triggers += 1
end)
assert(triggers == 1, "did not run initially")
setA(true)
assert(triggers == 2, "did not update indirectly")
end)
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
local a, setA = signal(0)
local b, setB = signal(0)
local c, setC = signal(0)
local d = computed(function()
c()
return 0
end)
local effect3Executed = false
effect(function()
a()
end)
effect(function()
if a() == 2 then
error("Error in effect 2")
end
end)
effect(function()
a()
d()
effect3Executed = true
end)
effect(function()
b()
end)
setA(1)
effect3Executed = false
local success, err = pcall(setA, 2)
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
setB(1)
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
setC(1)
assert(effect3Executed, "effect 3 should have executed after updating signal c")
end)
test("should allow finite recursion", function()
local src, setSrc = signal(0)
local order = {}
effect(function()
table.insert(order, src())
setSrc(math.min(src() + 1, 5))
end)
assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
end)
test("should run cleanups in sequence", function()
local order = {}
local a, setA = signal(false)
local stopEffect = effect(function()
a()
onCleanup(function()
table.insert(order, "a")
end)
onCleanup(function()
table.insert(order, "b")
end)
end)
assert(table.concat(order) == "", "should not cleanup immediately")
setA(true)
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
table.clear(order)
setA(false)
assert(table.concat(order) == "", "should not cleanup after update")
end)
test("should run inner effect cleanup", function()
local cleanups = 0
local src, setSrc = signal(false)
local stopEffect = effect(function()
src()
effect(function()
onCleanup(function()
cleanups += 1
end)
end)
effect(function()
onCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
setSrc(true)
assert(cleanups == 2, "should cleanup after outer update")
stopEffect()
assert(cleanups == 4, "should cleanup after outer dispose")
setSrc(false)
assert(cleanups == 4, "should not cleanup after update")
end)
test("should run cleanups outside any evaluation context", function()
local calls = 0
local a, setA = signal(0)
local b, setB = signal(0)
local c = computed(function()
if a() == 0 then
effect(function()
onCleanup(function()
b()
end)
end)
end
return a()
end)
effect(function()
calls += 1
c()
end)
assert(calls == 1, "should run once")
calls = 0
setA(1)
assert(calls == 1, "should run once after a update")
calls = 0
setB(1)
assert(calls == 0, "should not run after b update")
end)
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
local a, setA = signal(0)
local calls = {}
effect(function()
table.insert(calls, a())
return function()
setA(2)
end
end)
assert(#calls == 1, "should have run once initially")
table.clear(calls)
setA(1)
assert(#calls == 1, "should run once after an update")
assert(calls[1] == 2, `bad call: {calls[1]}`)
end)
test("should run the cleanup if the effect disposes itself", function()
local a, setA = signal(0)
local calls = 0
local dispose: () -> ()
dispose = effect(function()
if a() > 0 then
dispose()
return function()
calls += 1
end
end
end)
assert(calls == 0, "should not have called cleanup initially")
setA(1)
assert(calls == 1, "should have called cleanup once after first update")
setA(2)
assert(calls == 1, "should not call cleanup again after second update")
end)
test("should not run the effect if the cleanup function disposes it", function()
local a, setA = signal(0)
local calls = 0
local dispose: () -> ()
dispose = effect(function()
a()
calls += 1
return function()
dispose()
end
end)
assert(calls == 1, "should have run effect once initially")
setA(1)
assert(calls == 1, "should not run effect again after update")
end)
test("should reset the cleanup if the effect throws", function()
local a, setA = signal(0)
local calls = 0
effect(function()
if a() == 0 then
return function()
calls += 1
end
else
error("hello")
end
end)
assert(calls == 0, "should not have called cleanup initially")
local success, err = pcall(setA, 1)
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
assert(calls == 1, "should have called cleanup once after throwing")
setA(0)
assert(calls == 1, "should not call cleanup again after resetting value")
end)
test("recursive effects should run and cleanup in order", function()
local a, setA = signal(0)
local order: { string } = {}
effect(function()
local value = a()
table.insert(order, `+{value}`)
if value < 3 then
setA(value + 1)
end
return function()
table.insert(order, `-{value}`)
end
end)
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
table.clear(order)
setA(100)
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
end)
test("should effects dispose in order", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect = effect(function()
src()
for i = 1, 3 do
effect(function()
return function()
table.insert(order, i)
end
end)
end
end)
assert(#order == 0, "should not cleanup immediately")
setSrc(true)
assert(table.concat(order) == "321", `bad rerun order: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "321", `bad dispose order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test("should cleanup nested effects deepest first", function()
local order: { string } = {}
local stopEffect = effect(function()
effect(function()
effect(function()
return function()
table.insert(order, "grandchild")
end
end)
return function()
table.insert(order, "child")
end
end)
return function()
table.insert(order, "outer")
end
end)
stopEffect()
assert(table.concat(order, ", ") == "grandchild, child, outer", `bad dispose order: {table.concat(order, ", ")}`)
end)
test("should cleanup computed child effects in reverse when unwatched", function()
local order: { number } = {}
local c = computed(function()
for i = 1, 3 do
effect(function()
return function()
table.insert(order, i)
end
end)
end
return 0
end)
local stopEffect = effect(function()
c()
end)
stopEffect()
assert(table.concat(order) == "321", `bad dispose order: {table.concat(order)}`)
end)
test("should handle inner effect clearing outer effect's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
end)
setSrc(true)
end)
test("should handle inner effect cleanup disposing outer effect", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
table.insert(order, 0)
for i = 1, 4 do
effect(function()
return function()
table.insert(order, i)
stopEffect()
end
end)
end
end)
assert(table.concat(order) == "0", "should not cleanup immediately")
table.clear(order)
setSrc(true)
assert(table.concat(order) == "4321", `bad rerun order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test("should cleanup child before parent on rerun", function()
local order: { string } = {}
local src, setSrc = signal(false)
effect(function()
src()
table.insert(order, "outer run")
effect(function()
table.insert(order, "inner run")
return function()
table.insert(order, "inner cleanup")
end
end)
return function()
table.insert(order, "outer cleanup")
end
end)
table.clear(order)
setSrc(true)
assert(
table.concat(order, ", ") == "inner cleanup, outer cleanup, outer run, inner run",
`bad rerun order: {table.concat(order, ", ")}`
)
end)
test("should preserve child tracking after an inner-only rerun", function()
local order: { string } = {}
local outerSrc, setOuterSrc = signal(false)
local innerSrc, setInnerSrc = signal(false)
effect(function()
outerSrc()
table.insert(order, "outer run")
effect(function()
innerSrc()
table.insert(order, "inner run")
return function()
table.insert(order, "inner cleanup")
end
end)
return function()
table.insert(order, "outer cleanup")
end
end)
setInnerSrc(true)
table.clear(order)
setOuterSrc(true)
assert(
table.concat(order, ", ") == "inner cleanup, outer cleanup, outer run, inner run",
`bad rerun order: {table.concat(order, ", ")}`
)
end)
return {}