mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add detached, add more tests
This commit is contained in:
parent
7c48bd2fa4
commit
e276863b2d
10 changed files with 327 additions and 77 deletions
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
79
pkgs/charm/benchmarks/mapped.bench.luau
Normal file
79
pkgs/charm/benchmarks/mapped.bench.luau
Normal 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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 nextItems = selector()
|
||||
|
||||
if nextItems ~= currentItems then
|
||||
local previousItems = currentItems
|
||||
currentItems = tracked()
|
||||
update(currentItems, previousItems)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
72
pkgs/charm/tests/gc.spec.luau
Normal file
72
pkgs/charm/tests/gc.spec.luau
Normal 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 {}
|
||||
29
pkgs/charm/tests/listen.spec.luau
Normal file
29
pkgs/charm/tests/listen.spec.luau
Normal 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 {}
|
||||
64
pkgs/charm/tests/observe.spec.luau
Normal file
64
pkgs/charm/tests/observe.spec.luau
Normal 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 {}
|
||||
|
|
@ -15,6 +15,9 @@ local function spec(name: string)
|
|||
end
|
||||
|
||||
collect("signals")
|
||||
spec("gc")
|
||||
spec("subscribe")
|
||||
spec("listen")
|
||||
spec("observe")
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue