feat: add detached, add more tests

This commit is contained in:
littensy 2025-05-06 02:26:36 -07:00
parent 7c48bd2fa4
commit e276863b2d
10 changed files with 327 additions and 77 deletions

View file

@ -1,39 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local computed = Charm.computed
local effect = Charm.effect
local atom = Charm.atom
return {
Functions = {
atom = function()
local state = atom(0)
effect(function()
state()
end)
for _ = 1, 500 do
state(math.random(1, 2))
end
end,
computed = function()
local state = atom(0)
local double = computed(function()
return 2 * state()
end)
effect(function()
double()
end)
for _ = 1, 500 do
state(math.random(1, 2))
end
end,
},
}

View file

@ -0,0 +1,79 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local mapped = Charm.mapped
type Person = {
name: string,
age: number,
}
local personCount = 1000
local initialState: { Person } = table.create(personCount)
for i = 1, personCount do
initialState[i] = {
name = "Person " .. i,
age = math.random(1, 100),
}
end
local function mapper(person: Person): Person
return {
name = person.name .. "!",
age = person.age + 1,
}
end
local function simpleMapped(source: () -> { Person }): () -> { Person }
return computed(function()
local state = table.clone(source())
for index, person in state do
state[index] = mapper(person)
end
return state
end)
end
local function updatePercentageOfPeople(percent: number): ({ Person }) -> { Person }
return function(state)
local nextState = table.clone(state)
for index, person in state do
if math.random() < percent then
nextState[index] = mapper(person)
end
end
return nextState
end
end
return {
Functions = {
["mapped 50%"] = function(profiler)
profiler.Begin("init")
local src = atom(initialState)
local out = mapped(src, mapper)
out()
profiler.End()
profiler.Begin("update")
src(updatePercentageOfPeople(0.5))
profiler.End()
end,
["simple 50%"] = function(profiler)
profiler.Begin("init")
local src = atom(initialState)
local out = simpleMapped(src)
out()
profiler.End()
profiler.Begin("update")
src(updatePercentageOfPeople(0.5))
profiler.End()
end,
},
}

View file

@ -3,13 +3,13 @@ local signals = require("./signals")
local _effectScope = signals.effectScope
local onScopeDispose = signals.onScopeDispose
local function effectScope(callback: () -> ...() -> ()?): () -> ()
local function effectScope(callback: () -> ...() -> ()?, detached: boolean?): () -> ()
return _effectScope(function()
local cleanup = callback()
if cleanup then
onScopeDispose(cleanup)
end
end)
end, detached)
end
return effectScope

View file

@ -1,59 +1,71 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local getCurrentSub = signals.getCurrentSub
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local function noop() end
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local tracked = computed(function()
return selector()
end)
local cleanups: { [K]: () -> () } = {}
local scopes: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {}
local function update(currentItems: { [K]: V }, previousItems: { [K]: V })
for key in previousItems do
local cleanup = cleanups[key]
if currentItems[key] == nil and cleanup then
cleanups[key] = nil
cleanup()
if currentItems[key] == nil then
local dispose = scopes[key]
scopes[key] = nil
dispose()
end
end
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key) or noop
scopes[key] = effectScope(function()
local cleanup = observer(value, key)
if cleanup then
onScopeDispose(cleanup)
end
end, true)
end
end
end
local function teardown()
for _, cleanup in cleanups do
cleanup()
local function disposeScopes()
for _, dispose in scopes do
dispose()
end
table.clear(cleanups)
table.clear(scopes)
end
local disposeEffect = effect(function()
local previousItems = currentItems
currentItems = tracked()
update(currentItems, previousItems)
local nextItems = selector()
if nextItems ~= currentItems then
local previousItems = currentItems
currentItems = nextItems
pauseTracking()
local success, result: unknown = pcall(update, currentItems, previousItems)
resumeTracking()
if not success then
error(`observe() encountered an error: {result}`, 2)
end
end
end)
if getCurrentSub() then
onEffectCleanup(teardown)
onEffectCleanup(disposeScopes)
else
onScopeDispose(teardown)
onScopeDispose(disposeScopes)
end
return function()
disposeEffect()
teardown()
disposeScopes()
end
end

View file

@ -352,11 +352,11 @@ local function effect(fn: () -> ()): () -> ()
return effectStop(e)
end
local function effectScope(fn: () -> ()): () -> ()
local function effectScope(fn: () -> (), detached: boolean?): () -> ()
local e: EffectScope = {
flags = NONE,
}
if activeScope then
if activeScope and not detached then
link(e, activeScope)
end
local prev = setCurrentScope(e)

View file

@ -0,0 +1,72 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local effectScope = Charm.effectScope
local test = suite.test
--- Creates a weak reference to a value.
--- @return Function to dereference the value.
local function weakRef<T>(value: T): () -> T?
local ref = setmetatable({ value = value }, { __mode = "v" })
return function()
return ref.value
end
end
--- Triggers garbage collection by allocating excessive memory.
local function gc()
for _ = 1, 1000 do
local _ = table.create(100, 0)
end
end
--- In alien-signals, a computed value unlinks from dependencies when its last
--- parent scope is disposed. Calling a computed value outside of a scope links
--- it to dependencies, but the two-way link holds a strong reference to the
--- computed value, so it won't GC until called in a scope and disposed.
---
--- 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
--- and allow it to be garbage collected.
local function release(getter: () -> ())
effectScope(function()
getter()
end)()
end
test("should release computed cache", function()
local srcRef = weakRef({})
local src = atom(srcRef())
local c = computed(function()
return src()
end)
c() -- cache src value
src(nil) -- release value
c = release(c) -- release computed
gc()
assert(srcRef() == nil, "should release computed cache")
end)
test("should not release effect for atom", function()
local effectCalls = 0
local src = atom(0)
effect(function()
effectCalls += 1
src()
end)
assert(effectCalls == 1, "should call effect once")
gc()
src(src() + 1)
assert(effectCalls == 2, "should call effect again")
end)
return {}

View file

@ -0,0 +1,29 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local atom = Charm.atom
local listen = Charm.listen
local test = suite.test
test("should listen to selector", function()
local source = atom(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")
source(0)
assert(current == 1 and previous == nil, "should skip redundant update")
source(1)
assert(current == 2 and previous == 1, "should run with new value")
unsub()
source(2)
assert(current == 2 and previous == 1, "should not run after unsub")
end)
return {}

View file

@ -0,0 +1,64 @@
local Charm = require("../src/lib")
local suite = require("@tests/suite")
local atom = Charm.atom
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 updates = {}
local cleanups = {}
observe(items, function(_, key)
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)
items({ 1, 2, 3 })
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
table.clear(updates)
items({ 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 runs = {}
local dispose = observe(items, function(item)
effect(function()
table.insert(runs, item)
a()
end)
end)
table.clear(runs)
items({ 1, 2 })
a(1)
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
table.clear(runs)
dispose()
a(2)
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
end)
return {}

View file

@ -15,6 +15,9 @@ local function spec(name: string)
end
collect("signals")
spec("gc")
spec("subscribe")
spec("listen")
spec("observe")
return {}

View file

@ -1,28 +1,58 @@
local atom = require("../src/atom")
local subscribe = require("../src/subscribe")
local Charm = require("../src/lib")
local suite = require("@tests/suite")
type Atom<T> = Charm.Atom<T>
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local test = suite.test
test("should subscribe to atom", function()
test("should subscribe to selector", function()
local source = atom(0)
local current, previous
local unsub = subscribe(source, function(a, b)
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")
source(0)
assert(not current and not previous, "should skip redundant update")
source(1)
assert(current == 1, "should run with new value")
assert(previous == 0, "should run with old value")
source(2)
assert(current == 2, "should run with new value 2")
assert(previous == 1, "should run with old value 2")
assert(current == 2 and previous == 1, "should run with new value")
unsub()
source(3)
assert(current == 2, "should not run after unsubscribing")
source(2)
assert(current == 2 and previous == 1, "should not run after unsub")
end)
test("should ensure correct execution order", function()
local order = {}
local n1 = atom(0)
local n2 = atom(0)
local sum = computed(function()
return n1() + n2()
end)
subscribe(n1, function()
table.insert(order, 1)
n2(n2() + 1)
end)
subscribe(sum, function()
table.insert(order, 2)
end)
subscribe(n1, function()
table.insert(order, 3)
end)
n1(n1() + 1)
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
end)
return {}