feat!: use alien-signals for reactivity (#50)

* feat: use alien-signals

* refactor: remove pnpm, fix tests

* fix: `effectScope` should set scope

* fix: check previous value before calling listener

* refactor: improve imports

* refactor: exclude re-exports from wally

* refactor: embrace scope cleanup, restructure code

* fix: update pkgs dir references

* ci: simplify pesde install

* build: install dev deps with wally

* feat: add detached, add more tests

* test: simplify mapped test update

* chore: rephrase doc comment

* perf: optimize subscribe and mapped

* refactor: avoid ambiguous optional function type

* refactor: add assertion error messages

* perf: optimize mapped, add benches

* chore: delete redundant benchmarks

* perf: simplify mapped logic

* test: prettify test library

* test: try different gray

* test: color brackets

* refactor: sync 2.0.4

* refactor: use setCurrentSub instead of pauseTracking

* test: improve test suite

* test: separate parent from root

* refactor: iterate over previous data in mapped

* refactor: improve logic

* style: use `---` doc comments

* refactor: improve logic

* chore: housekeeping

* ci: add pnpm step

* ci: init submodules

* test: use lune

* refactor: improve structure and alien-signals integration

* ci: update test file path

* feat: add peek and roblox-ts types

* feat: add deep freeze flag, misc refactors

* refactor: reduce type casting, housekeeping

* refactor: rewrite untracked, update alien-signals

* chore: update eslint, housekeeping

* refactor: start charm-sync rewrite

* fix: send init payload even if atom did not update

* test: add all sync tests

* feat: prepare for alpha release

* feat: add peek, make untracked escape scope

* fix: wrap hooks in untracked, update types

* feat: add flag for inner effect tracking

* refactor: add docs, remove some modules

* refactor: align with alien-signals 3.0

* feat: add signal api

* refactor: update charm sync api

* feat: update alien-signals

* refactor: remove unnecessary features, update docs

* feat: finalize sync docs

* docs: update sync section

* docs: simplify examples

* docs: capitalize headers

* docs: add migration tips

* docs: fix whitespace

* docs: rephrase computed arg change

* refactor: rename batch, misc fixes & perf updates

* refactor: simplify charm sync validation

* feat: update alien-signals, add recursion flag

* feat: add `recursive()`

* fix: allow dispose in observers

* feat: add `trigger()`, refactor source

* docs: update readme

* chore: prepare release candidate

* test: make some tests more reliable

* docs: fix typo

* feat: add `reactive()`

* chore: bump versions

* docs: reword reactive docs

* docs: fix typo

* feat: add `toRaw()`

* fix: check for nil diffs

* chore: bump versions

* perf: do not create proxies for iteration

* fix: make `toRaw` track all calls

* fix: check none before patching mutable tables

* chore: bump charm-sync version

* docs: add fishing game example project

* feat: sync signals that return reactive proxy

* fix: refine reactive proxy logic

* fix: prevent early reactive gc, alien-signals 3.1.1

* fix: wrap iterated values in `reactive()`

* fix: loosen client signal types

* chore: bump release candidate

* fix: remove unsynced signals immediately

* test: use lute for tests

* chore: bump versions

* ci: set up lute

* refactor: improve source readability

* ci: upgrade lute

* build: loosen workspace version requirements

* chore: bump deps

* fix: update signal overload for setter (#58)

* fix: add missing recursed check

* test: add important vue reactivity tests

* chore: fix react tests, run stylua

* chore: update vide

* docs: improve intro and explain getters

* docs: adjust ordering by importance

* docs: move listen nesting note

* build: fix react peer dep for ci

* chore: update lockfile

* ci: use javascript eslint config

* ci: ignore eslint config file

* refactor: move test directories

* feat: allow recursion in effects by default

* release: charm v0.11.0-rc.4

* refactor: remove recursive()

* refactor: reorganize repo, create deep-charm

* docs: rephrase some sections

* feat: export getter and setter types

* docs: rephrase intro again

* feat!: run listen and subscribe callback in untracked

* refactor!: remove peek

* docs: note peek in migration guide

* release: charm v0.11.0-rc.5

* release: charm-sync v0.4.0-rc.7

* release: react-charm v0.4.0-rc.4

* fix: remove old keys when updating key in `mapped()`

* refactor: shorten error traceback, housekeeping

* refactor: keep error level in some cases

* fix: check if cleanup functions disposed effect

* refactor!: run effects in batched

* ci: check eslint and prettier

* perf: run observe fn in untracked

* perf: call mapper as-is in `mapped()`

* refactor: error for effect cleanup errors

* refactor: use new type solver

* ci: disable new solver flag until 0.714

* chore: housekeeping

* refactor: improve errors in nonstrict, optimize noyield

* chore: housekeeping

* refactor: satisfy new solver

* refactor: improve error traceback

* docs: link roblox signals

* chore: housekeeping

* perf: remove `clearPending` inline operation

* refactor: check type of cleanup

* ci: remove selene

* fix: always return pending value in signal setter

* test: add multi benchmark

* release: charm v0.11.0-rc.6

* test: add cleanup order tests

* chore: bump deps

* docs: add warning for deep-charm and note for `observe()`

---------

Co-authored-by: safazi <safazics@gmail.com>
This commit is contained in:
richard 2026-06-20 20:51:17 -07:00 • committed by GitHub
parent d0b7cff943
commit 573533058f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 9222 additions and 4384 deletions

View file

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

View file

@ -0,0 +1,576 @@
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) == "123", `bad rerun order: {table.concat(order)}`)
table.clear(order)
stopEffect()
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
test("should handle inner effect clearing outer effect's deps on rerun", function()
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
effect(function()
return function()
stopEffect()
end
end)
effect(function() end)
end)
setSrc(true)
end)
test.skip("should handle inner effect cleanup disposing outer effect", function()
local order: { number } = {}
local src, setSrc = signal(false)
local stopEffect: () -> ()
stopEffect = effect(function()
src()
table.insert(order, 0)
for i = 1, 4 do
effect(function()
return function()
table.insert(order, i)
stopEffect()
end
end)
end
end)
assert(table.concat(order) == "0", "should not cleanup immediately")
table.clear(order)
setSrc(true)
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
table.clear(order)
setSrc(false)
assert(#order == 0, "should not cleanup after dispose")
end)
return {}

View file

@ -0,0 +1,95 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local effect = Charm.effect
local effectScope = Charm.effectScope
local signal = Charm.signal
local onCleanup = Charm.onCleanup
test("should not trigger after stop", function()
local count, setCount = signal(1)
local triggers = 0
local stopScope = effectScope(function()
effect(function()
triggers += 1
count()
end)
assert(triggers == 1, "did not run initially")
setCount(2)
assert(triggers == 2, "did not run after update")
end)
setCount(3)
assert(triggers == 3, "did not run after update outside scope")
stopScope()
setCount(4)
assert(triggers == 3, "ran after stop")
end)
test("should run indirect scope cleanup", function()
local cleanups = 0
local cleanup: () -> ()
local stopScope = effectScope(function()
cleanup = effectScope(function()
onCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
stopScope()
assert(cleanups == 1, "should cleanup from parent dispose")
cleanup()
assert(cleanups == 1, "should skip redundant dispose")
end)
test("should run indirect effect cleanup", function()
local cleanups = 0
local a, setA = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
onCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
setA(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
setA(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should dispose inner effects if created in an effect", function()
local source, setSource = signal(1)
local triggers = 0
local dispose: () -> ()
effect(function()
dispose = effectScope(function()
effect(function()
source()
triggers += 1
end)
end)
assert(triggers == 1, "did not run initially")
end)
setSource(2)
assert(triggers == 2, "did not run after update")
dispose()
setSource(3)
assert(triggers == 2, "ran after dispose")
end)
return {}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,77 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local listen = Charm.listen
test("should listen to selector", function()
local source, setSource = signal(0)
local current, previous
local unsub = listen(function()
return source() + 1
end, function(a, b)
current = a
previous = b
end)
assert(current == 1 and previous == nil, "should run initially")
setSource(0)
assert(current == 1 and previous == nil, "should skip redundant update")
setSource(1)
assert(current == 2 and previous == 1, "should run with new value")
unsub()
setSource(2)
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
local runs = 0
local a, setA = signal(0)
local b, setB = signal(0)
listen(a, function()
runs += 1
b()
end)
assert(runs == 1, "should run once on init")
setA(1)
assert(runs == 2, "should run once on change")
setB(1)
assert(runs == 2, "should not run on b change")
setA(2)
assert(runs == 3, "should run on a change")
end)
test("should run if first value is nil", function()
local runs = 0
local source, setSource = signal(nil :: number?)
listen(source, function()
runs += 1
end)
assert(runs == 1, "should run once on init")
setSource(1)
assert(runs == 2, "should run once on change")
end)
test("should skip redundant updates", function()
local order = {}
local a, setA = signal(0)
listen(function(): "even" | "odd"
return if a() % 2 == 0 then "even" else "odd"
end, function(value)
table.insert(order, value)
end)
setA(1)
setA(3)
setA(4)
setA(6)
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -0,0 +1,128 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local mapped = Charm.mapped
test("should map state", function()
local src, setSrc = signal({ 1, 2, 3 })
local map = mapped(src, function(value: number)
return value + 1
end)
assert(table.concat(map()) == "234", table.concat(map()))
setSrc({ 4, 5, 6 })
assert(table.concat(map()) == "567", table.concat(map()))
end)
test("should not map unchanged values", function()
local calls = 0
local src, setSrc = signal({ 1, 2, 3 })
local map = mapped(src, function(value: number)
calls += 1
return value + 1
end)
assert(table.concat(map()) == "234", table.concat(map()))
assert(calls == 3, "should map 3 values")
setSrc({ 1, 3, 3 })
assert(table.concat(map()) == "244", table.concat(map()))
assert(calls == 4, "should map 1 change")
setSrc({})
assert(#map() == 0, "should be empty")
assert(calls == 4, "should not map for removed values")
end)
test("should not update if no values change", function()
local calls = 0
local src, setSrc = signal({ 1, 2, 3 })
local map = mapped(src, function(value: number)
calls += 1
return value + 1
end)
local prev = map()
assert(calls == 3, "should map 3 values")
assert(table.concat(map()) == "234", table.concat(map()))
setSrc({ 4, 5, 6, 7 })
setSrc({ 1, 2, 3 })
map()
assert(calls == 3, "should not map unchanged values")
assert(table.concat(map()) == "234", table.concat(map()))
assert(prev == map(), "should not create new table")
end)
test("should filter values", function()
local calls = 0
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number)
calls += 1
return if value < 0 then nil else value
end)
local state = map()
assert(calls == 3, "should map 3 values")
assert(state.a and state.b and state.c, "should map all values")
calls = 0
setSrc({ a = -1, b = 2, c = 3, d = 4 })
state = map()
assert(calls == 2, "should map 2 values")
assert(state.a == nil, "should remove a")
assert(state.b and state.c, "should keep b and c")
assert(state.d == 4, "should add d")
end)
test("should map keys", function()
local calls = 0
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
local map = mapped(src, function(value: number, key: string)
calls += 1
return if value < 0 then nil else value, string.upper(key)
end)
local state = map()
assert(calls == 3, "should map 3 values")
assert(not (state.a and state.b and state.c), "should discard old keys")
assert(state.A == 1, "should map a")
assert(state.B == 2, "should map b")
assert(state.C == 3, "should map c")
calls = 0
setSrc({ a = -1, b = 2, c = 3, d = 4 })
state = map()
assert(calls == 2, "should map 2 values")
assert(state.A == nil and state.a == nil, "should remove a")
assert(state.B and state.C, "should keep b and c")
assert(state.D == 4, "should add d")
end)
test("should remove old key if mapper returns a new key", function()
local src, setSrc = signal({ 1 })
local map = mapped(src, function(value: number, index: number)
return true, `{index}={value}`
end)
local state = map()
assert(state["1=1"], "should have mapped first element to key 1=1")
setSrc({ 2 })
state = map()
assert(state["1=1"] == nil, "should have removed old key 1=1")
assert(state["1=2"], "should have remapped element to key 1=2")
setSrc({ 3, 1 })
state = map()
assert(state["1=2"] == nil, "should have removed old key 1=2")
assert(state["1=3"], "should have remapped element to key 1=3")
assert(state["2=1"], "should have mapped second element to key 2=1")
end)
return {}

View file

@ -0,0 +1,198 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local observe = Charm.observe
test("should observe items", function()
local items, setItems = signal({ 0, 0, 0 })
local updates = {}
local cleanups = {}
observe(items, function(_, key: number)
local get = computed(function()
return items()[key]
end)
effect(function()
table.insert(updates, get())
end)
return function()
table.insert(cleanups, key)
end
end)
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
table.clear(updates)
setItems({ 1, 2, 3 })
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
table.clear(updates)
setItems({ 1, 2, nil :: any, 4 })
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
end)
test("should dispose scopes", function()
local items, setItems = signal({ 1, 2, 3 })
local a, setA = signal(0)
local runs = {}
local disposes = {}
local dispose = observe(items, function(item)
effect(function()
table.insert(runs, item)
a()
end)
effectScope(function()
return function()
table.insert(disposes, item)
end
end)
end)
table.clear(runs)
setItems({ 1, 2 })
setA(1)
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
table.clear(runs)
table.clear(disposes)
dispose()
setA(2)
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
end)
test("should queue addition and removal", function()
local items, setItems = signal({ 1 })
local edits = {}
observe(items, function(item)
if item == 1 then
setItems({ 1, 2, 3, 4 })
elseif item == 4 then
setItems({ 1 })
end
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
end)
test("should handle recursive additions and removals", function()
local items, setItems = signal({ 1 })
local edits: { string } = {}
observe(items, function(item)
if item < 5 then
setItems(function(state: { number })
state = table.clone(state)
table.insert(state, item + 1)
return state
end)
else
setItems({})
end
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
end)
test("should handle self removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits: { string } = {}
observe(items, function(item, index)
setItems(function(state: { number })
state = table.clone(state)
state[index] = nil
return state
end)
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
end)
test("should allow disposal during addition", function()
local items, setItems = signal({})
local edits = {}
local dispose: () -> ()
dispose = observe(items, function(item)
if item == 1 then
dispose()
end
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
setItems({ 1, 2, 3, 4 })
assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`)
end)
test("should allow disposal during removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {}
local dispose: () -> ()
dispose = observe(items, function(item)
table.insert(edits, `+{item}`)
return function()
dispose()
table.insert(edits, `-{item}`)
end
end)
setItems({ 1, 2, 3, nil :: any, 5 })
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
end)
test("should not hang on error after recursive update", function()
local items, setItems = signal({})
observe(items, function()
setItems({ {} })
error("test error")
end)
local success, err = pcall(function()
setItems({ {} })
end)
assert(not success, "should error")
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
end)
test("should not hang on error after recursive init", function()
local items, setItems = signal({ {} })
local success, err = pcall(function()
observe(items, function()
setItems({ {} })
error("test error")
end)
end)
assert(not success, "should error")
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
end)
return {}

View file

@ -0,0 +1,67 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local effect = Charm.effect
test("should accept equality function", function()
local calls = {}
local src, setSrc = signal(0, function(current: number, incoming: number)
return current == incoming or incoming % 2 == 1
end)
effect(function()
table.insert(calls, src())
end)
setSrc(2)
setSrc(3)
assert(src() == 2, "should skip odd value")
setSrc(4)
setSrc(5)
assert(src() == 4, "should skip odd value")
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
end)
test("should enforce immutability", function()
local src, setSrc = signal({ nested = {} })
assert(not pcall(function()
src().nested.a = 1
end), "should not allow mutation of initial value")
setSrc({ nested = { a = 1 } })
assert(not pcall(function()
src().nested.a = 2
end), "should not allow mutation of new value")
end)
test("should not freeze values with metatables", function()
local src = signal({ object = setmetatable({}, {}) })
assert(
pcall(function()
src().object.a = 2
end),
"should allow mutation of values with metatables"
)
end)
test("should setter return same value as getter even if equal", function()
local src, setSrc = signal(0, function()
return true
end)
assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`)
end)
test("should setter return latest value", function()
local src, setSrc = signal(0)
effect(function()
if src() == 1 then
setSrc(2)
end
end)
assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`)
assert(src() == 2, `src() expected 2, got {src()}`)
end)
return {}

View file

@ -0,0 +1,91 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local computed = Charm.computed
local subscribe = Charm.subscribe
test("should subscribe to selector", function()
local source, setSource = signal(0)
local current, previous
local unsub = subscribe(function()
return source() + 1
end, function(a, b)
current = a
previous = b
end)
assert(not current and not previous, "should not run initially")
setSource(0)
assert(not current and not previous, "should skip redundant update")
setSource(1)
assert(current == 2 and previous == 1, "should run with new value")
unsub()
setSource(2)
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should run in untracked", function()
local runs = 0
local a, setA = signal(0)
local b, setB = signal(0)
subscribe(a, function()
runs += 1
b()
end)
assert(runs == 0, "should not run once on init")
setA(1)
assert(runs == 1, "should run once on change")
setB(1)
assert(runs == 1, "should not run on b change")
setA(2)
assert(runs == 2, "should run on a change")
end)
test("should ensure correct execution order", function()
local order: { number } = {}
local n1, setN1 = signal(0)
local n2, setN2 = signal(0)
local sum = computed(function()
return n1() + n2()
end)
subscribe(n1, function()
table.insert(order, 1)
setN2(n2() + 1)
end)
subscribe(sum, function()
table.insert(order, 2)
end)
subscribe(n1, function()
table.insert(order, 3)
end)
setN1(n1() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end)
test("should skip redundant updates", function()
local order: { "even" | "odd" } = {}
local a, setA = signal(0)
subscribe(function(): "even" | "odd"
return if a() % 2 == 0 then "even" else "odd"
end, function(value)
table.insert(order, value)
end)
setA(1)
setA(3)
setA(4)
setA(6)
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -0,0 +1,484 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
--[[
Tests adopted with thanks from preact-core implementation at
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
The MIT License (MIT)
Copyright (c) 2022-present Preact Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
]]
test.describe("graph updates", function()
test("should drop A->B->A updates", function()
-- A
-- / |
-- B | <- Looks like a flag doesn't it? :D
-- \ |
-- C
-- |
-- D
local a = atom(2)
local b = computed(function()
return a() - 1
end)
local c = computed(function()
return a() + b()
end)
local computeCalls = 0
local compute = function()
computeCalls += 1
return "d: " .. c()
end
local d = computed(compute)
-- Trigger read
assert(d() == "d: 3", "d should be 3")
assert(computeCalls == 1, "compute should be called once")
computeCalls = 0
a(4)
d()
assert(computeCalls == 1, "compute should drop update")
end)
test("should only update every signal once (diamond graph)", function()
-- In this scenario "D" should only update once when "A" receives
-- an update. This is sometimes referred to as the "diamond" scenario.
-- A
-- / \
-- B C
-- \ /
-- D
local a = atom("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b() .. " " .. c()
end
local d = computed(spy)
assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
test("should only update every signal once (diamond graph + tail)", function()
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
-- A
-- / \
-- B C
-- \ /
-- D
-- |
-- E
local a = atom("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local d = computed(function()
return b() .. " " .. c()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return d()
end
local e = computed(spy)
assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes
-- A->B->C
local a = atom("a")
local b = computed(function()
a()
return "foo"
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b()
end
local c = computed(spy)
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out")
end)
test("should only update every signal once (jagged diamond graph + tails)", function()
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
-- A
-- / \
-- B C
-- | |
-- | D
-- \ /
-- E
-- / \
-- F G
local a = atom("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local d = computed(function()
return c()
end)
local eSpyCalls = 0
local eSpyTime = 0
local eSpy = function()
eSpyCalls += 1
eSpyTime = os.clock()
return b() .. " " .. d()
end
local e = computed(eSpy)
local fSpyCalls = 0
local fSpyTime = 0
local fSpy = function()
fSpyCalls += 1
fSpyTime = os.clock()
return e()
end
local f = computed(fSpy)
local gSpyCalls = 0
local gSpyTime = 0
local gSpy = function()
gSpyCalls += 1
gSpyTime = os.clock()
return e()
end
local g = computed(gSpy)
assert(f() == "a a", "f should be 'a a'")
assert(fSpyCalls == 1, "f should be called once")
assert(g() == "a a", "g should be 'a a'")
assert(gSpyCalls == 1, "g should be called once")
eSpyCalls, eSpyTime = 0, 0
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
a("b")
assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update")
assert(f() == "b b", "f should be 'b b'")
assert(fSpyCalls == 1, "f should run once after update")
assert(g() == "b b", "g should be 'b b'")
assert(gSpyCalls == 1, "g should run once after update")
eSpyCalls, eSpyTime = 0, 0
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
a("c")
assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update")
assert(f() == "c c", "f should be 'c c'")
assert(fSpyCalls == 1, "f should run once after update")
assert(g() == "c c", "g should be 'c c'")
assert(gSpyCalls == 1, "g should run once after update")
-- top to bottom
assert(eSpyTime < fSpyTime, "e should be called before f")
-- left to right
assert(eSpyTime < gSpyTime, "e should be called before g")
end)
test("should only subscribe to core listened to", function()
-- *A
-- / \
-- *B C <- we don't listen to C
local a = atom("a")
local b = computed(function()
return a()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return a()
end
computed(spy)
assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run")
a("aa")
assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run")
end)
test("should only subscribe to core listened to II", function()
-- Here both "B" and "C" are active in the beginning, but
-- "B" becomes inactive later. At that point it should
-- not receive any updates anymore.
-- *A
-- / \
-- *B D <- we don't listen to C
-- |
-- *C
local a = atom("a")
local spyBCalls = 0
local spyB = function()
spyBCalls += 1
return a()
end
local b = computed(spyB)
local spyCCalls = 0
local spyC = function()
spyCCalls += 1
return b()
end
local c = computed(spyC)
local d = computed(function()
return a()
end)
local result = ""
local unsub = effect(function()
result = c()
end)
assert(result == "a", "result should be 'a'")
assert(d() == "a", "d should be 'a'")
spyBCalls = 0
spyCCalls = 0
unsub()
a("aa")
assert(spyBCalls == 0, "b should not run")
assert(spyCCalls == 0, "c should not run")
assert(d() == "aa", "d should be 'aa'")
end)
test("should ensure subs update even if one dep unmarks it", function()
-- In this scenario "C" always returns the same value. When "A"
-- changes, "B" will update, then "C" at which point its update
-- to "D" will be unmarked. But "D" must still update because
-- "B" marked it. If "D" isn't updated, then we have a bug.
-- A
-- / \
-- B *C <- returns same value every time
-- \ /
-- D
local a = atom("a")
local b = computed(function()
return a()
end)
local c = computed(function()
a()
return "c"
end)
local spyResult: string?
local spy = function()
spyResult = b() .. " " .. c()
return spyResult
end
local d = computed(spy)
assert(d() == "a c", "d should be 'a c'")
spyResult = nil
a("aa")
d()
assert(spyResult == "aa c", "d should have returned 'aa c'")
end)
test("should ensure subs update even if two deps unmark it", function()
-- In this scenario both "C" and "D" always return the same
-- value. But "E" must still update because "A" marked it.
-- If "E" isn't updated, then we have a bug.
-- A
-- / | \
-- B *C *D
-- \ | /
-- E
local a = atom("a")
local b = computed(function()
return a()
end)
local c = computed(function()
a()
return "c"
end)
local d = computed(function()
a()
return "d"
end)
local spyResult: string?
local spy = function()
spyResult = b() .. " " .. c() .. " " .. d()
return spyResult
end
local e = computed(spy)
assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil
a("aa")
e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end)
test("should support lazy branches", function()
local a = atom(0)
local b = computed(function()
return a()
end)
local c = computed(function()
return if a() > 0 then a() else b()
end)
assert(c() == 0, "c should be 0")
a(1)
assert(c() == 1, "c should be 1")
a(0)
assert(c() == 0, "c should be 0 again")
end)
test("should not update a sub if all deps unmark it", function()
-- In this scenario "B" and "C" always return the same value. When "A"
-- changes, "D" should not update.
-- A
-- / \
-- *B *C
-- \ /
-- D
local a = atom("a")
local b = computed(function()
a()
return "b"
end)
local c = computed(function()
a()
return "c"
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b() .. " " .. c()
end
local d = computed(spy)
assert(d() == "b c", "d should be 'b c'")
spyCalls = 0
a("aa")
assert(spyCalls == 0, "d should not run")
end)
end)
test.describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
local a = atom(0)
local b = computed(function(): nil
error("fail")
end)
local c = computed(function()
return a()
end)
local success, result = pcall(b)
assert(not success, "should throw")
assert(string.match(tostring(result), "fail"), "should throw fail")
a(1)
assert(c() == 1, "c should recover and return 1")
end)
test("should keep graph consistent on errors in computeds", function()
local a = atom(0)
local b = computed(function(): number
if a() == 1 then
error("fail")
end
return a()
end)
local c = computed(function()
return b()
end)
assert(c() == 0, "c should be 0")
a(1)
local success, result = pcall(b)
assert(not success, "should throw")
assert(string.match(tostring(result), "fail"), "should throw fail")
a(2)
assert(c() == 2, "c should recover and return 2")
end)
end)
return {}

View file

@ -0,0 +1,111 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local signal = Charm.signal
local subscribe = Charm.subscribe
local trigger = Charm.trigger
test.beforeEach(function()
Charm.flags.frozen = false
end)
test.afterEach(function()
Charm.flags.frozen = true
end)
test("should not throw when triggering with no dependencies", function()
trigger(function() end)
end)
test("should trigger updates for dependent computed signals", function()
local arr = signal({} :: { number })
local length = computed(function()
return #arr()
end)
assert(length() == 0, "initial length should be 0")
table.insert(arr(), 1)
trigger(arr)
assert(length() == 1, "length should be 1")
end)
test("should trigger updates for the second source signal", function()
local src1 = signal({} :: { number })
local src2 = signal({} :: { number })
local length = computed(function()
return #src2()
end)
assert(length() == 0, "initial length should be 0")
table.insert(src2(), 1)
trigger(function()
src1()
src2()
end)
assert(length() == 1, "length should be 1")
end)
test("should trigger effect once", function()
local src1 = signal({} :: { number })
local src2 = signal({} :: { number })
local triggers = 0
effect(function()
triggers += 1
src1()
src2()
end)
assert(triggers == 1, "effect should run once initially")
trigger(function()
src1()
src2()
end)
assert(triggers == 2, "effect should run twice")
end)
test("should not notify the trigger function sub", function()
local src1 = signal({} :: { number })
local src2 = computed(function()
return src1()
end)
effect(function()
src1()
src2()
end)
trigger(function()
src1()
src2()
end)
end)
test("should trigger updates for subscribe", function()
local src = signal({} :: { number })
local triggers = 0
subscribe(src, function()
triggers += 1
end)
trigger(src)
assert(triggers == 1, "subscribe callback should run after trigger")
end)
test("should trigger updates for listen", function()
local src = signal({} :: { number })
local triggers = 0
listen(src, function()
triggers += 1
end)
assert(triggers == 1, "listen callback should run once initially")
trigger(src)
assert(triggers == 2, "listen callback should run after trigger")
end)
return {}

View file

@ -0,0 +1,176 @@
local Charm = require("../src")
local test = require("../../../tests/test")
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local untracked = Charm.untracked
test("should run detached from effect", function()
local a, setA = signal(1)
local effects = 0
local innerEffects = {}
local stopEffect = effect(function()
a()
untracked(function()
local stopInnerEffect = effect(function()
effects += 1
return function()
effects -= 1
end
end)
table.insert(innerEffects, stopInnerEffect)
end)
end)
assert(effects == 1, "should have one effect")
setA(2)
assert(effects == 2, "should have two effects")
stopEffect()
assert(effects == 2, "should still have two effects")
for _, stopInnerEffect in innerEffects do
stopInnerEffect()
end
assert(effects == 0, "should have zero effects")
end)
test("should run detached from effect scope", function()
local effects = 0
local stopEffect: () -> ()
local stopScope = effectScope(function()
untracked(function()
stopEffect = effect(function()
effects += 1
return function()
effects -= 1
end
end)
end)
end)
assert(effects == 1, "should have one effect")
stopScope()
assert(effects == 1, "should still have one effect")
stopEffect()
assert(effects == 0, "should have zero effects")
end)
test("should block tracking inside effects", function()
local a, setA = signal(1)
local b, setB = signal(2)
local spyCalls = 0
effect(function()
untracked(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 1, "should be called once")
setA(10)
setB(20)
assert(spyCalls == 1, "should be called once")
end)
test("should block tracking even when run inside effect run inside untracked", function()
local s, setS = signal(1)
local spyCalls = 0
untracked(function()
effect(function()
untracked(function()
spyCalls += 1
return s()
end)
end)
end)
assert(spyCalls == 1, "should be called once")
setS(2)
assert(spyCalls == 1, "should be called once")
end)
test("should not cause signal assignments to throw", function()
local a, setA = signal(1)
local aChangedTime, setAChangedTime = signal(0)
local dispose = effect(function()
a()
untracked(function()
setAChangedTime(aChangedTime() + 1)
end)
end)
setA(2)
assert(aChangedTime() == 2, "aChangedTime should be 2")
setA(3)
assert(aChangedTime() == 3, "aChangedTime should be 3")
dispose()
end)
test("should block tracking inside computed signals", function()
local a, setA = signal(1)
local b, setB = signal(2)
local spyCalls = 0
local c = computed(function()
return untracked(function()
spyCalls += 1
return a() + b()
end)
end)
assert(spyCalls == 0, "should not be called yet")
assert(c() == 3, "c should be 3")
setA(10)
c()
setB(20)
c()
assert(spyCalls == 1, "should be called once")
assert(c() == 3, "c should be 3")
end)
test("should track outside untracked", function()
local a, setA = signal(1)
local b, setB = signal(2)
local c, setC = signal(3)
local spyCalls = 0
effect(function()
a()
untracked(c)
b()
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
setA(10)
assert(spyCalls == 2, "should be called twice")
setB(20)
assert(spyCalls == 3, "should be called three times")
setC(30)
assert(spyCalls == 3, "should not run on c change")
end)
test("should stack untracked", function()
local a, setA = signal(1)
local spyCalls = 0
effect(function()
untracked(function()
untracked(a)
return a()
end)
spyCalls += 1
end)
assert(spyCalls == 1, "should be called once")
setA(2)
assert(spyCalls == 1, "should not be called twice")
end)
return {}