From e276863b2d217acfb889530867d8c0970d6334a6 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Tue, 6 May 2025 02:26:36 -0700 Subject: [PATCH] feat: add detached, add more tests --- pkgs/charm/benchmarks/effect.bench.luau | 39 ------------ pkgs/charm/benchmarks/mapped.bench.luau | 79 +++++++++++++++++++++++++ pkgs/charm/src/effectScope.luau | 4 +- pkgs/charm/src/observe.luau | 58 +++++++++++------- pkgs/charm/src/signals.luau | 4 +- pkgs/charm/tests/gc.spec.luau | 72 ++++++++++++++++++++++ pkgs/charm/tests/listen.spec.luau | 29 +++++++++ pkgs/charm/tests/observe.spec.luau | 64 ++++++++++++++++++++ pkgs/charm/tests/spec.luau | 3 + pkgs/charm/tests/subscribe.spec.luau | 52 ++++++++++++---- 10 files changed, 327 insertions(+), 77 deletions(-) delete mode 100644 pkgs/charm/benchmarks/effect.bench.luau create mode 100644 pkgs/charm/benchmarks/mapped.bench.luau create mode 100644 pkgs/charm/tests/gc.spec.luau create mode 100644 pkgs/charm/tests/listen.spec.luau create mode 100644 pkgs/charm/tests/observe.spec.luau diff --git a/pkgs/charm/benchmarks/effect.bench.luau b/pkgs/charm/benchmarks/effect.bench.luau deleted file mode 100644 index 45ab9c7..0000000 --- a/pkgs/charm/benchmarks/effect.bench.luau +++ /dev/null @@ -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, - }, -} diff --git a/pkgs/charm/benchmarks/mapped.bench.luau b/pkgs/charm/benchmarks/mapped.bench.luau new file mode 100644 index 0000000..327136c --- /dev/null +++ b/pkgs/charm/benchmarks/mapped.bench.luau @@ -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, + }, +} diff --git a/pkgs/charm/src/effectScope.luau b/pkgs/charm/src/effectScope.luau index a324bb9..c23ed4f 100644 --- a/pkgs/charm/src/effectScope.luau +++ b/pkgs/charm/src/effectScope.luau @@ -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 diff --git a/pkgs/charm/src/observe.luau b/pkgs/charm/src/observe.luau index 4f07020..6d467d4 100644 --- a/pkgs/charm/src/observe.luau +++ b/pkgs/charm/src/observe.luau @@ -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(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 diff --git a/pkgs/charm/src/signals.luau b/pkgs/charm/src/signals.luau index a265fee..f5222d5 100644 --- a/pkgs/charm/src/signals.luau +++ b/pkgs/charm/src/signals.luau @@ -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) diff --git a/pkgs/charm/tests/gc.spec.luau b/pkgs/charm/tests/gc.spec.luau new file mode 100644 index 0000000..6173496 --- /dev/null +++ b/pkgs/charm/tests/gc.spec.luau @@ -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(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 {} diff --git a/pkgs/charm/tests/listen.spec.luau b/pkgs/charm/tests/listen.spec.luau new file mode 100644 index 0000000..15d7457 --- /dev/null +++ b/pkgs/charm/tests/listen.spec.luau @@ -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 {} diff --git a/pkgs/charm/tests/observe.spec.luau b/pkgs/charm/tests/observe.spec.luau new file mode 100644 index 0000000..57da889 --- /dev/null +++ b/pkgs/charm/tests/observe.spec.luau @@ -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 {} diff --git a/pkgs/charm/tests/spec.luau b/pkgs/charm/tests/spec.luau index 7ae1948..7a60b4d 100644 --- a/pkgs/charm/tests/spec.luau +++ b/pkgs/charm/tests/spec.luau @@ -15,6 +15,9 @@ local function spec(name: string) end collect("signals") +spec("gc") spec("subscribe") +spec("listen") +spec("observe") return {} diff --git a/pkgs/charm/tests/subscribe.spec.luau b/pkgs/charm/tests/subscribe.spec.luau index 1199594..c2d5bd5 100644 --- a/pkgs/charm/tests/subscribe.spec.luau +++ b/pkgs/charm/tests/subscribe.spec.luau @@ -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 = Charm.Atom + +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 {}