feat: update alien-signals

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,30 @@
--[[
Reactive system adapted from alien-signals at
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
MIT License
Copyright (c) 2024-present Johnson Chu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
export type ReactiveNode = {
deps: Link?,
depsTail: Link?,
@ -23,14 +50,14 @@ type Stack<T> = {
export type ReactiveFlags = number
local NONE = 0b00000000
local MUTABLE = 0b00000001
local WATCHING = 0b00000010
local RECURSED_CHECK = 0b00000100
local RECURSED = 0b00001000
local DIRTY = 0b00010000
local PENDING = 0b00100000
local PEEKING = 0b10000000
local NONE = 0b0000000
local MUTABLE = 0b0000001
local WATCHING = 0b0000010
local RECURSED_CHECK = 0b0000100
local RECURSED = 0b0001000
local DIRTY = 0b0010000
local PENDING = 0b0100000
local PEEKING = 0b1000000
local ReactiveFlags = {
None = NONE,

View file

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

View file

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

View file

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