feat: use alien-signals

This commit is contained in:
littensy 2025-05-03 18:24:39 -07:00
parent d7a1f62c3c
commit 861ee5acd7
76 changed files with 3112 additions and 1862 deletions

View file

@ -0,0 +1,82 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local computed = signals.computed
local signal = signals.signal
test("should correctly propagate changes through computed signals", function()
local src = 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()
src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
src(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value")
end)
test("should propagate updated source value through chained computations", function()
local src = signal(0)
local 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")
src(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a = 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")
a(true)
assert(d() == true, "bad updated value")
end)
test("should not update if the signal value is reverted", function()
local times = 0
local src = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
src(1)
src(0)
c1()
assert(times == 1, "computed ran when it should not")
end)
return {}

View file

@ -0,0 +1,337 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local endBatch = signals.endBatch
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
local startBatch = signals.startBatch
test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0
local a = signal(1)
local b = computed(function()
bRunTimes += 1
return a() * 2
end)
local stopEffect = effect(function()
b()
end)
assert(bRunTimes == 1, "should run initially")
a(2)
assert(bRunTimes == 2, "should run after change")
stopEffect()
a(3)
assert(bRunTimes == 2, "should not run after stop")
end)
test("should not run untracked inner effect", function()
local a = 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)
a(2)
a(1)
a(0)
end)
test("should run outer effect first", function()
local a = signal(1)
local b = signal(1)
effect(function()
if a() > 0 then
effect(function()
b()
if a() == 0 then
error("bad")
end
end)
end
end)
startBatch()
b(0)
a(0)
endBatch()
end)
test("should not trigger inner effect when resolve maybe dirty", function()
local a = 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)
a(2)
end)
test("should trigger inner effects in sequence", function()
local a = signal(0)
local b = signal(0)
local c = computed(function()
return a() - b()
end)
local order: { string } = {}
effect(function()
c()
effect(function()
table.insert(order, "first inner")
a()
end)
effect(function()
table.insert(order, "last inner")
a()
b()
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 = signal(0)
local b = signal(0)
local order: { string } = {}
effectScope(function()
effect(function()
table.insert(order, "first inner")
a()
end)
effect(function()
table.insert(order, "last inner")
a()
b()
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 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 = signal(0)
local aa = computed(function()
table.insert(logs, "aa-0")
if a() == 0 then
b(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 = signal(0)
local src2 = signal(0)
local order: { string } = {}
effect(function()
table.insert(order, "a")
pauseTracking()
local isOne = src2() == 1
resumeTracking()
if isOne then
src1()
end
src2()
src1()
end)
effect(function()
table.insert(order, "b")
src1()
end)
src2(1) -- src1.subs: a -> b -> a
table.clear(order)
src1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end)
test("should handle side effect with inner effects", function()
local a = signal(0)
local b = signal(0)
local 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)
b(1)
a(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false)
local 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")
a(true)
assert(triggers == 2, "did not update indirectly")
end)
test("should run effect cleanup", function()
local cleanups = 0
local a = signal(false)
local cleanup = effect(function()
a()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
cleanup()
assert(cleanups == 2, "should cleanup after effect stop")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup in scope", function()
local cleanups = 0
local a = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup", function()
local cleanups = 0
local a = signal(false)
local stopScope = effect(function()
a()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup after outer update")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
return {}

View file

@ -0,0 +1,66 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local effect = signals.effect
local effectScope = signals.effectScope
local signal = signals.signal
test("should not trigger after stop", function()
local count = signal(1)
local triggers = 0
local stopScope = effectScope(function()
effect(function()
triggers += 1
count()
end)
assert(triggers == 1, "did not run initially")
count(2)
assert(triggers == 2, "did not run after update")
end)
count(3)
assert(triggers == 3, "did not run after update outside scope")
stopScope()
count(4)
assert(triggers == 3, "ran after stop")
end)
test("should run effect cleanup", function()
local cleanups = 0
local cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
cleanup()
assert(cleanups == 1, "should cleanup after effect stop")
cleanup()
assert(cleanups == 1, "should skip redundant cleanup")
end)
test("should not run indirect effect cleanup", function()
local cleanups = 0
local cleanup
local stopScope = effectScope(function()
cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
stopScope()
assert(cleanups == 0, "should be detached from parent dispose")
cleanup()
assert(cleanups == 1, "should cleanup after inner dispose")
end)
return {}

View file

@ -0,0 +1,113 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local computed = signals.computed
local effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
type ReactionOptions<T = unknown> = {
fireImmediately: boolean?,
equals: ((a: T, b: T?) -> boolean)?,
onError: (error: unknown) -> ()?,
scheduler: (fn: () -> ()) -> ()?,
once: boolean?,
}
local function untracked<T>(callback: () -> T): T
pauseTracking()
local success, result = pcall(callback)
resumeTracking()
if not success then
error(result)
end
return result
end
local function reaction<T>(
dataFn: () -> T,
effectFn: (newValue: T, oldValue: T?) -> (),
options: ReactionOptions<T>?
): () -> ()
local options: ReactionOptions<T> = options or {}
local scheduler: (() -> ()) -> () = options.scheduler or function(fn)
return fn()
end
local equals: (T, T?) -> boolean = options.equals or function(a, b)
return a == b
end
local onError = options.onError
local once = options.once or false
local fireImmediately = options.fireImmediately or false
local prevValue: T?
local version = 0
local tracked = computed(function()
local success, result = pcall(dataFn)
if not success then
untracked(function()
if onError then
onError(result)
end
return nil
end)
return prevValue
end
return result
end)
local dispose
dispose = effect(function()
local current = tracked()
if not fireImmediately and version == 0 then
prevValue = current
end
version += 1
if equals(current :: T, prevValue) then
return
end
local oldValue = prevValue
prevValue = current
untracked(function()
scheduler(function()
local success = pcall(effectFn, current :: T, oldValue)
if not success and onError then
onError(error)
end
if once then
if (fireImmediately and version > 1) or (not fireImmediately and version > 0) then
dispose()
end
end
end)
return nil
end)
end)
return dispose
end
test("#48", function()
local source = signal(0)
local disposeInner: () -> ()
reaction(function()
return source()
end, function(val)
if val == 1 then
disposeInner = reaction(function()
return source()
end, function() end)
elseif val == 2 then
disposeInner()
end
end)
source(1)
source(2)
source(3)
end)
return {}

18
tests/signals/spec.luau Normal file
View file

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

View file

@ -0,0 +1,493 @@
local signals = require("../../packages/charm/src/signals")
local suite = require("../suite")
local test = suite.test
local describe = suite.describe
local computed = signals.computed
local effect = signals.effect
local signal = signals.signal
--[[
Tests adopted with thanks from preact-signals 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
]]
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 = signal(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 = signal("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 = signal("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 = signal("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 = signal("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 signals listened to", function()
-- *A
-- / \
-- *B C <- we don't listen to C
local a = signal("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 signals 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 = signal("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 = signal("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 = signal("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 = signal(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 = signal("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)
describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
local a = signal(0)
local b = computed(function()
error("fail")
end)
local c = computed(function()
return a()
end)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(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 = signal(0)
local b = computed(function()
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(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
a(2)
assert(c() == 2, "c should recover and return 2")
end)
end)
return {}

View file

@ -0,0 +1,23 @@
local test = require("../suite").test
local signals = require("../../packages/charm/src/signals")
local computed = signals.computed
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
test("should pause tracking", function()
local src = signal(0)
local c = computed(function()
pauseTracking()
local value = src()
resumeTracking()
return value
end)
assert(c() == 0, "should not track src")
src(1)
assert(c() == 0, "should not track src")
end)
return {}