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 _effectScope = signals.effectScope
local onScopeDispose = signals.onScopeDispose local onScopeDispose = signals.onScopeDispose
local function effectScope(callback: () -> ...() -> ()?): () -> () local function effectScope(callback: () -> ...() -> ()?, detached: boolean?): () -> ()
return _effectScope(function() return _effectScope(function()
local cleanup = callback() local cleanup = callback()
if cleanup then if cleanup then
onScopeDispose(cleanup) onScopeDispose(cleanup)
end end
end) end, detached)
end end
return effectScope return effectScope

View file

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

View file

@ -352,11 +352,11 @@ local function effect(fn: () -> ()): () -> ()
return effectStop(e) return effectStop(e)
end end
local function effectScope(fn: () -> ()): () -> () local function effectScope(fn: () -> (), detached: boolean?): () -> ()
local e: EffectScope = { local e: EffectScope = {
flags = NONE, flags = NONE,
} }
if activeScope then if activeScope and not detached then
link(e, activeScope) link(e, activeScope)
end end
local prev = setCurrentScope(e) 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 end
collect("signals") collect("signals")
spec("gc")
spec("subscribe") spec("subscribe")
spec("listen")
spec("observe")
return {} return {}

View file

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