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 _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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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
|
end
|
||||||
|
|
||||||
collect("signals")
|
collect("signals")
|
||||||
|
spec("gc")
|
||||||
spec("subscribe")
|
spec("subscribe")
|
||||||
|
spec("listen")
|
||||||
|
spec("observe")
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue