refactor: embrace scope cleanup, restructure code

This commit is contained in:
littensy 2025-05-05 22:05:41 -07:00
parent 3eba56e298
commit 8d385356cd
81 changed files with 687 additions and 626 deletions

View file

@ -1,44 +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
local cleanup: () -> ()
return {
AfterEach = function()
cleanup()
end,
Functions = {
atom = function()
local state = atom(0)
cleanup = 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)
cleanup = effect(function()
double()
end)
for _ = 1, 500 do
state(math.random(1, 2))
end
end,
},
}

View file

@ -1,50 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local patch = CharmSync.patch
local USERS = 1000
local function generate()
local users = {}
for user = 1, USERS do
users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
return { users = users }
end
local function update(state)
state = table.clone(state)
state.users = table.clone(state.users)
for user = 1, USERS do
if math.random() > 0.5 then
continue
end
state.users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
return state
end
return {
ParameterGenerator = function()
local state = generate()
return state, update(state)
end,
Functions = {
diff = function(_, a, b)
patch.diff(a, b)
end,
},
}

View file

@ -1,39 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local signals = Charm.signals
local function propagate(
signal: <T>(T) -> (T?) -> T,
computed: <T>(() -> T) -> () -> T,
effect: (() -> ()) -> (),
w: number,
h: number
)
local src = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end
return {
Functions = {
charm = function()
propagate(Charm.atom, Charm.computed, Charm.effect, 10, 10)
end,
alien_signals = function()
propagate(signals.signal, signals.computed, signals.effect, 10, 10)
end,
},
}

View file

@ -1,59 +0,0 @@
local Charm = require("../charm")
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local signal = Charm.signals.signal
local computedSignal = Charm.signals.computed
local effectSignal = Charm.signals.effect
local w = 10
local h = 10
local function bench(name: string, test: () -> ())
local start = os.clock()
local iter = 0
for _ = 1, 15000 do
iter += 1
test()
end
local endTime = os.clock()
local elapsed = endTime - start
print(`{name}: {elapsed / iter * 1000 * 1000} us/iter`)
end
bench(`charm {w} * {h}`, function()
local src = atom(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computed(function()
return prev() + 1
end)
end
effect(function()
last()
end)
end
src(src() + 1)
end)
bench(`alien-signals {w} * {h}`, function()
local src = signal(1)
for _ = 1, w do
local last = src
for _ = 1, h do
local prev = last
last = computedSignal(function()
return prev() + 1
end)
end
effectSignal(function()
last()
return nil
end)
end
src(src() + 1)
end)

View file

@ -1,125 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local subscribe = Charm.subscribe
local client = CharmSync.client
local test = suite.test
test("should sync state payload", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = 2 },
})
assert(a() == 2)
end)
test("should sync state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
assert(a() == previous)
client:sync({
type = "patch",
data = { a = { b = 3 } },
})
assert(a() ~= previous)
assert(a().b == 3)
assert(a().c == 2)
end)
test("should fix sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client:sync({
type = "patch",
data = { a = { ["2"] = 2 } },
})
assert(a()["2" :: any] == nil)
assert(a()[2] == 2)
end)
test("should apply multiple patches", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
local calls = {}
subscribe(a, function(value)
table.insert(calls, value)
end)
client:sync({
type = "init",
data = { a = a() },
})
client:sync({
type = "patch",
data = { a = 2 },
}, {
type = "patch",
data = { a = 3 },
})
assert(a() == 3)
assert(calls[1] == 2)
assert(calls[2] == 3)
end)
test("should batch patches", function()
local a = atom(0)
local b = atom(0)
local client = client({ atoms = { a = a, b = b } })
local states = {}
subscribe(a, function()
states.b = b()
end)
subscribe(b, function()
states.a = a()
end)
client:sync({
type = "init",
data = { a = a(), b = b() },
})
client:sync({
type = "patch",
data = { a = 1, b = 2 },
})
assert(a() == 1)
assert(b() == 2)
assert(states.a == 1)
assert(states.b == 2)
end)
return {}

View file

@ -1,28 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local flatten = CharmSync.flatten
local test = suite.test
test("should flatten atoms", function()
local input = {
a = atom(0),
b = { c = atom(0) },
d = { e = { f = atom(0) } },
}
local result = flatten(input)
assert(result.a == input.a)
assert(result["b/c"] == input.b.c)
assert(result["d/e/f"] == input.d.e.f)
assert(result.b == nil)
assert(result.d == nil)
end)
return {}

View file

@ -1,129 +0,0 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require("../suite")
local patch = CharmSync.patch
local test = suite.test
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
assert(patches ~= nil)
assert(patches.baz == "quux")
assert(patches.foo == nil)
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
assert(applied ~= nil)
assert(applied.baz == "quux")
assert(applied.foo == "bar")
assert(applied.qaz == "qux")
end)
test("allows nested patches", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
assert(applied.foo.bar == "qux")
end)
test("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
assert(applied[1] == "a")
assert(applied[2] == "B")
assert(applied[3] == "c")
assert(applied[4] == "D")
assert(applied["2"] == nil)
assert(applied["4"] == nil)
end)
test("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
assert(applied[index] == nextState[index])
end
for index in next, applied do
assert(applied[index] == nextState[index])
end
end)
test("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
assert(applied[index] == nextState[index])
end
end)
test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil)
assert(applied[2] == nil)
end)
test("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
assert(type(index) == "string")
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
assert(type(index) == "number")
end
end)
test("optionally validates state", function()
local success = pcall(function()
patch.diff({}, { [0.1] = true }, true)
end)
assert(not success)
success = pcall(function()
patch.diff({}, { [0.1] = true }, false)
end)
assert(success)
end)
return {}

View file

@ -1,102 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
type SyncPayload = CharmSync.SyncPayload
local atom = Charm.atom
local server = CharmSync.server
local patch = CharmSync.patch
local test = suite.test
local target: Player = {} :: any
test("sends state to new players", function()
local a = atom(1)
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
server:hydrate(target)
assert(player == target)
assert(payload.type == "init")
assert(payload.data.a == 1)
disconnect()
end)
test("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
a({ b = 1, c = 5 })
server:_sendPatch(target)
assert(player == target)
assert(payload.type == "patch")
assert(payload.data.a.b == nil)
assert(payload.data.a.c == 5)
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.data.a.b == nil)
assert(patch.isNone(payload.data.a.c) == true)
assert(payload.data.a.d == 1)
disconnect()
end)
test("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { SyncPayload }
local server = server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
assert(#payloads == 1)
assert(payloads[1].type == "patch")
assert(payloads[1].data.a == 2)
assert(payloads[1].data.b == 2)
a(3)
a(4)
b(3)
server:_sendPatch(target)
assert(#payloads == 2)
assert(payloads[1].type == "patch")
assert(payloads[1].data.a == 3)
assert(payloads[1].data.b == nil)
assert(payloads[2].type == "patch")
assert(payloads[2].data.a == 4)
assert(payloads[2].data.b == 3)
disconnect()
end)
return {}

View file

@ -1,17 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("client")
test("server")
test("flatten")
test("patch")
test("validate")
return {}

View file

@ -1,38 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local validate = CharmSync.validate
local test = suite.test
test("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
map_infinity_key = { value = { [math.huge] = true }, expected = false },
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
nested_atom = { value = atom(0), expected = false },
}
for key, case in next, cases do
local success, result = pcall(function()
validate(case.value, key)
end)
if case.expected and not success then
error(`Expected {key} to be valid, threw {result}`)
elseif not case.expected and success then
error(`Expected {key} to be invalid, but it was valid`)
end
end
end)
return {}

View file

@ -1,3 +0,0 @@
return if game
then require(game:GetService("ReplicatedStorage").Packages.Charm)
else require("../packages/charm/src/lib")

14
tests/init.luau Normal file
View file

@ -0,0 +1,14 @@
local suite = require("@tests/suite")
local describe = suite.describe
local finish = suite.finish
if not table.isfrozen(_G) then
_G.__DEV__ = true
end
describe("charm", function()
require("@pkgs/charm/__tests__/spec")
end)
finish()

View file

@ -1,13 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("useAtom")
return {}

View file

@ -1,50 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local renderHook = require(ServerScriptService.Tests.utils.renderHook)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local useAtom = ReactCharm.useAtom
local test = suite.test
test("tracks atom state", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state)
end)
assert(hook.result == 0)
state(1)
assert(hook.result == 1)
end)
test("accepts a selector", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(function()
return 2 * state()
end)
end)
assert(hook.result == 0)
state(1)
assert(hook.result == 2)
end)
test("tracks dependencies", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state, { state })
end)
assert(hook.result == 0)
state(1)
assert(hook.result == 1)
state = atom(0)
hook.rerender()
assert(hook.result == 0)
state(1)
assert(hook.result == 1)
end)
return {}

View file

@ -1,84 +0,0 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = charm.signals
local computed = signals.computed
local signal = signals.signal
local test = suite.test
test("should correctly propagate changes through computed signals", function()
local src = signal(0)
local c1 = computed(function()
return src() % 2
end)
local c2 = computed(function()
return c1()
end)
local c3 = computed(function()
return c2()
end)
c3()
src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none
src(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value")
end)
test("should propagate updated source value through chained computations", function()
local src = signal(0)
local a = computed(function()
return src()
end)
local b = computed(function()
return a() % 2
end)
local c = computed(function()
return src()
end)
local d = computed(function()
return b() + c()
end)
assert(d() == 0, "bad initial value")
src(2)
assert(d() == 2, "bad updated value")
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
assert(d() == false, "bad initial value")
a(true)
assert(d() == true, "bad updated value")
end)
test("should not update if the signal value is reverted", function()
local times = 0
local src = signal(0)
local c1 = computed(function()
times += 1
return src()
end)
c1()
assert(times == 1, "computed did not run")
src(1)
src(0)
c1()
assert(times == 1, "computed ran when it should not")
end)
return {}

View file

@ -1,339 +0,0 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = charm.signals
local computed = signals.computed
local effect = signals.effect
local effectScope = signals.effectScope
local endBatch = signals.endBatch
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
local startBatch = signals.startBatch
local test = suite.test
test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0
local a = signal(1)
local b = computed(function()
bRunTimes += 1
return a() * 2
end)
local stopEffect = effect(function()
b()
end)
assert(bRunTimes == 1, "should run initially")
a(2)
assert(bRunTimes == 2, "should run after change")
stopEffect()
a(3)
assert(bRunTimes == 2, "should not run after stop")
end)
test("should not run untracked inner effect", function()
local a = signal(3)
local b = computed(function()
return a() > 0
end)
effect(function()
if b() then
effect(function()
if a() == 0 then
error("bad")
end
end)
end
end)
a(2)
a(1)
a(0)
end)
test("should run outer effect first", function()
local a = signal(1)
local b = signal(1)
effect(function()
if a() > 0 then
effect(function()
b()
if a() == 0 then
error("bad")
end
end)
end
end)
startBatch()
b(0)
a(0)
endBatch()
end)
test("should not trigger inner effect when resolve maybe dirty", function()
local a = signal(0)
local b = computed(function()
return a() % 2
end)
local innerTriggerTimes = 0
effect(function()
effect(function()
b()
innerTriggerTimes += 1
if innerTriggerTimes >= 2 then
error("bad")
end
end)
end)
a(2)
end)
test("should trigger inner effects in sequence", function()
local a = signal(0)
local b = signal(0)
local c = computed(function()
return a() - b()
end)
local order: { string } = {}
effect(function()
c()
effect(function()
table.insert(order, "first inner")
a()
end)
effect(function()
table.insert(order, "last inner")
a()
b()
end)
end)
table.clear(order)
startBatch()
b(1)
a(1)
endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
end)
test("should trigger inner effects in sequence in effect scope", function()
local a = signal(0)
local b = signal(0)
local order: { string } = {}
effectScope(function()
effect(function()
table.insert(order, "first inner")
a()
end)
effect(function()
table.insert(order, "last inner")
a()
b()
end)
end)
table.clear(order)
startBatch()
b(1)
a(1)
endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
end)
test("should custom effect support batch", function()
local function batchEffect(fn: () -> ())
return effect(function()
startBatch()
local success, result: unknown = pcall(fn)
endBatch()
if not success then
error(result)
end
end)
end
local logs: { string } = {}
local a = signal(0)
local b = signal(0)
local aa = computed(function()
table.insert(logs, "aa-0")
if a() == 0 then
b(1)
end
table.insert(logs, "aa-1")
return nil
end)
local bb = computed(function()
table.insert(logs, "bb")
return b()
end)
batchEffect(function()
bb()
end)
batchEffect(function()
aa()
end)
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
end)
test("should duplicate subscribers do not affect the notify order", function()
local src1 = signal(0)
local src2 = signal(0)
local order: { string } = {}
effect(function()
table.insert(order, "a")
pauseTracking()
local isOne = src2() == 1
resumeTracking()
if isOne then
src1()
end
src2()
src1()
end)
effect(function()
table.insert(order, "b")
src1()
end)
src2(1) -- src1.subs: a -> b -> a
table.clear(order)
src1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end)
test("should handle side effect with inner effects", function()
local a = signal(0)
local b = signal(0)
local order: { string } = {}
effect(function()
effect(function()
a()
table.insert(order, "a")
end)
effect(function()
b()
table.insert(order, "b")
end)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
table.clear(order)
b(1)
a(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
end)
test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false)
local b = computed(function()
return a()
end)
local c = computed(function()
b()
return 0
end)
local d = computed(function()
c()
return b()
end)
local triggers = 0
effect(function()
d()
triggers += 1
end)
assert(triggers == 1, "did not run initially")
a(true)
assert(triggers == 2, "did not update indirectly")
end)
test("should run effect cleanup", function()
local cleanups = 0
local a = signal(false)
local cleanup = effect(function()
a()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
cleanup()
assert(cleanups == 2, "should cleanup after effect stop")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup in scope", function()
local cleanups = 0
local a = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
test("should run inner effect cleanup", function()
local cleanups = 0
local a = signal(false)
local stopScope = effect(function()
a()
effect(function()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup after outer update")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
a(false)
assert(cleanups == 2, "should not cleanup after update")
end)
return {}

View file

@ -1,68 +0,0 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = charm.signals
local effect = signals.effect
local effectScope = signals.effectScope
local signal = signals.signal
local test = suite.test
test("should not trigger after stop", function()
local count = signal(1)
local triggers = 0
local stopScope = effectScope(function()
effect(function()
triggers += 1
count()
end)
assert(triggers == 1, "did not run initially")
count(2)
assert(triggers == 2, "did not run after update")
end)
count(3)
assert(triggers == 3, "did not run after update outside scope")
stopScope()
count(4)
assert(triggers == 3, "ran after stop")
end)
test("should run effect cleanup", function()
local cleanups = 0
local cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
assert(cleanups == 0, "should not cleanup immediately")
cleanup()
assert(cleanups == 1, "should cleanup after effect stop")
cleanup()
assert(cleanups == 1, "should skip redundant cleanup")
end)
test("should run indirect effect cleanup", function()
local cleanups = 0
local cleanup
local stopScope = effectScope(function()
cleanup = effectScope(function()
return function()
cleanups += 1
end
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
stopScope()
assert(cleanups == 1, "should cleanup from parent dispose")
cleanup()
assert(cleanups == 1, "should skip redundant dispose")
end)
return {}

View file

@ -1,115 +0,0 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = charm.signals
local computed = signals.computed
local effect = signals.effect
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
local test = suite.test
type ReactionOptions<T = unknown> = {
fireImmediately: boolean?,
equals: ((a: T, b: T?) -> boolean)?,
onError: (error: unknown) -> ()?,
scheduler: (fn: () -> ()) -> ()?,
once: boolean?,
}
local function untracked<T>(callback: () -> T): T
pauseTracking()
local success, result = pcall(callback)
resumeTracking()
if not success then
error(result)
end
return result
end
local function reaction<T>(
dataFn: () -> T,
effectFn: (newValue: T, oldValue: T?) -> (),
options: ReactionOptions<T>?
): () -> ()
local options: ReactionOptions<T> = options or {}
local scheduler: (() -> ()) -> () = options.scheduler or function(fn)
return fn()
end
local equals: (T, T?) -> boolean = options.equals or function(a, b)
return a == b
end
local onError = options.onError
local once = options.once or false
local fireImmediately = options.fireImmediately or false
local prevValue: T?
local version = 0
local tracked = computed(function()
local success, result = pcall(dataFn)
if not success then
untracked(function()
if onError then
onError(result)
end
return nil
end)
return prevValue
end
return result
end)
local dispose
dispose = effect(function()
local current = tracked()
if not fireImmediately and version == 0 then
prevValue = current
end
version += 1
if equals(current :: T, prevValue) then
return
end
local oldValue = prevValue
prevValue = current
untracked(function()
scheduler(function()
local success = pcall(effectFn, current :: T, oldValue)
if not success and onError then
onError(error)
end
if once then
if (fireImmediately and version > 1) or (not fireImmediately and version > 0) then
dispose()
end
end
end)
return nil
end)
end)
return dispose
end
test("#48", function()
local source = signal(0)
local disposeInner: () -> ()
reaction(function()
return source()
end, function(val)
if val == 1 then
disposeInner = reaction(function()
return source()
end, function() end)
elseif val == 2 then
disposeInner()
end
end)
source(1)
source(2)
source(3)
end)
return {}

View file

@ -1,18 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("computed")
test("effect")
test("effectScope")
test("issue_48")
test("topology")
test("untrack")
return {}

View file

@ -1,493 +0,0 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = charm.signals
local computed = signals.computed
local effect = signals.effect
local signal = signals.signal
local describe = suite.describe
local test = suite.test
--[[
Tests adopted with thanks from preact-signals implementation at
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
The MIT License (MIT)
Copyright (c) 2022-present Preact Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
]]
describe("graph updates", function()
test("should drop A->B->A updates", function()
-- A
-- / |
-- B | <- Looks like a flag doesn't it? :D
-- \ |
-- C
-- |
-- D
local a = signal(2)
local b = computed(function()
return a() - 1
end)
local c = computed(function()
return a() + b()
end)
local computeCalls = 0
local compute = function()
computeCalls += 1
return "d: " .. c()
end
local d = computed(compute)
-- Trigger read
assert(d() == "d: 3", "d should be 3")
assert(computeCalls == 1, "compute should be called once")
computeCalls = 0
a(4)
d()
assert(computeCalls == 1, "compute should drop update")
end)
test("should only update every signal once (diamond graph)", function()
-- In this scenario "D" should only update once when "A" receives
-- an update. This is sometimes referred to as the "diamond" scenario.
-- A
-- / \
-- B C
-- \ /
-- D
local a = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b() .. " " .. c()
end
local d = computed(spy)
assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
test("should only update every signal once (diamond graph + tail)", function()
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
-- A
-- / \
-- B C
-- \ /
-- D
-- |
-- E
local a = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local d = computed(function()
return b() .. " " .. c()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return d()
end
local e = computed(spy)
assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes
-- A->B->C
local a = signal("a")
local b = computed(function()
a()
return "foo"
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b()
end
local c = computed(spy)
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once")
a("aa")
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out")
end)
test("should only update every signal once (jagged diamond graph + tails)", function()
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
-- A
-- / \
-- B C
-- | |
-- | D
-- \ /
-- E
-- / \
-- F G
local a = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
return a()
end)
local d = computed(function()
return c()
end)
local eSpyCalls = 0
local eSpyTime = 0
local eSpy = function()
eSpyCalls += 1
eSpyTime = os.clock()
return b() .. " " .. d()
end
local e = computed(eSpy)
local fSpyCalls = 0
local fSpyTime = 0
local fSpy = function()
fSpyCalls += 1
fSpyTime = os.clock()
return e()
end
local f = computed(fSpy)
local gSpyCalls = 0
local gSpyTime = 0
local gSpy = function()
gSpyCalls += 1
gSpyTime = os.clock()
return e()
end
local g = computed(gSpy)
assert(f() == "a a", "f should be 'a a'")
assert(fSpyCalls == 1, "f should be called once")
assert(g() == "a a", "g should be 'a a'")
assert(gSpyCalls == 1, "g should be called once")
eSpyCalls, eSpyTime = 0, 0
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
a("b")
assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update")
assert(f() == "b b", "f should be 'b b'")
assert(fSpyCalls == 1, "f should run once after update")
assert(g() == "b b", "g should be 'b b'")
assert(gSpyCalls == 1, "g should run once after update")
eSpyCalls, eSpyTime = 0, 0
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
a("c")
assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update")
assert(f() == "c c", "f should be 'c c'")
assert(fSpyCalls == 1, "f should run once after update")
assert(g() == "c c", "g should be 'c c'")
assert(gSpyCalls == 1, "g should run once after update")
-- top to bottom
assert(eSpyTime < fSpyTime, "e should be called before f")
-- left to right
assert(eSpyTime < gSpyTime, "e should be called before g")
end)
test("should only subscribe to signals listened to", function()
-- *A
-- / \
-- *B C <- we don't listen to C
local a = signal("a")
local b = computed(function()
return a()
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return a()
end
computed(spy)
assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run")
a("aa")
assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run")
end)
test("should only subscribe to signals listened to II", function()
-- Here both "B" and "C" are active in the beginning, but
-- "B" becomes inactive later. At that point it should
-- not receive any updates anymore.
-- *A
-- / \
-- *B D <- we don't listen to C
-- |
-- *C
local a = signal("a")
local spyBCalls = 0
local spyB = function()
spyBCalls += 1
return a()
end
local b = computed(spyB)
local spyCCalls = 0
local spyC = function()
spyCCalls += 1
return b()
end
local c = computed(spyC)
local d = computed(function()
return a()
end)
local result = ""
local unsub = effect(function()
result = c()
end)
assert(result == "a", "result should be 'a'")
assert(d() == "a", "d should be 'a'")
spyBCalls = 0
spyCCalls = 0
unsub()
a("aa")
assert(spyBCalls == 0, "b should not run")
assert(spyCCalls == 0, "c should not run")
assert(d() == "aa", "d should be 'aa'")
end)
test("should ensure subs update even if one dep unmarks it", function()
-- In this scenario "C" always returns the same value. When "A"
-- changes, "B" will update, then "C" at which point its update
-- to "D" will be unmarked. But "D" must still update because
-- "B" marked it. If "D" isn't updated, then we have a bug.
-- A
-- / \
-- B *C <- returns same value every time
-- \ /
-- D
local a = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
a()
return "c"
end)
local spyResult: string?
local spy = function()
spyResult = b() .. " " .. c()
return spyResult
end
local d = computed(spy)
assert(d() == "a c", "d should be 'a c'")
spyResult = nil
a("aa")
d()
assert(spyResult == "aa c", "d should have returned 'aa c'")
end)
test("should ensure subs update even if two deps unmark it", function()
-- In this scenario both "C" and "D" always return the same
-- value. But "E" must still update because "A" marked it.
-- If "E" isn't updated, then we have a bug.
-- A
-- / | \
-- B *C *D
-- \ | /
-- E
local a = signal("a")
local b = computed(function()
return a()
end)
local c = computed(function()
a()
return "c"
end)
local d = computed(function()
a()
return "d"
end)
local spyResult: string?
local spy = function()
spyResult = b() .. " " .. c() .. " " .. d()
return spyResult
end
local e = computed(spy)
assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil
a("aa")
e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end)
test("should support lazy branches", function()
local a = signal(0)
local b = computed(function()
return a()
end)
local c = computed(function()
return if a() > 0 then a() else b()
end)
assert(c() == 0, "c should be 0")
a(1)
assert(c() == 1, "c should be 1")
a(0)
assert(c() == 0, "c should be 0 again")
end)
test("should not update a sub if all deps unmark it", function()
-- In this scenario "B" and "C" always return the same value. When "A"
-- changes, "D" should not update.
-- A
-- / \
-- *B *C
-- \ /
-- D
local a = signal("a")
local b = computed(function()
a()
return "b"
end)
local c = computed(function()
a()
return "c"
end)
local spyCalls = 0
local spy = function()
spyCalls += 1
return b() .. " " .. c()
end
local d = computed(spy)
assert(d() == "b c", "d should be 'b c'")
spyCalls = 0
a("aa")
assert(spyCalls == 0, "d should not run")
end)
end)
describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
local a = signal(0)
local b = computed(function()
error("fail")
end)
local c = computed(function()
return a()
end)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
a(1)
assert(c() == 1, "c should recover and return 1")
end)
test("should keep graph consistent on errors in computeds", function()
local a = signal(0)
local b = computed(function()
if a() == 1 then
error("fail")
end
return a()
end)
local c = computed(function()
return b()
end)
assert(c() == 0, "c should be 0")
a(1)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
a(2)
assert(c() == 2, "c should recover and return 2")
end)
end)
return {}

View file

@ -1,25 +0,0 @@
local charm = require("../charm")
local suite = require("../suite")
local signals = charm.signals
local computed = signals.computed
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local signal = signals.signal
local test = suite.test
test("should pause tracking", function()
local src = signal(0)
local c = computed(function()
pauseTracking()
local value = src()
resumeTracking()
return value
end)
assert(c() == 0, "should not track src")
src(1)
assert(c() == 0, "should not track src")
end)
return {}

View file

@ -3,19 +3,18 @@ local suite = require("./suite")
local describe = suite.describe
local finish = suite.finish
local function test(name: string)
describe(name, function()
return require(`./{name}/spec`) :: any
end)
end
_G.__DEV__ = true
test("signals")
describe("charm-sync", function()
require(script.Parent.CharmSync.spec)
end)
if game then
_G.__DEV__ = true
test("charm-sync")
test("react-charm")
test("vide-charm")
end
describe("react-charm", function()
require(script.Parent.ReactCharm.spec)
end)
describe("vide-charm", function()
require(script.Parent.VideCharm.spec)
end)
finish()

View file

@ -1,12 +0,0 @@
--[=[
Trigger a garbage collection cycle by allocating a large amount of junk memory
and then waiting for the next frame.
]=]
local function collect()
for _ = 1, 1e4 do
local _ = table.create(1e3)
end
task.wait()
end
return collect

View file

@ -1,15 +0,0 @@
--[=[
Counts the number of elements in a table.
@param object The table to count.
@return The number of elements in the table.
]=]
local function count(object: any): number
local count = 0
for _ in next, object do
count += 1
end
return count
end
return count

View file

@ -1,42 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
_G.__ROACT_17_MOCK_SCHEDULER__ = true
local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.Packages.ReactRoblox)
type RenderedHook<Props, Result> = {
result: Result,
rerender: (Props?) -> (),
unmount: () -> (),
}
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
local hook = {}
local function Component(props)
hook.result = callback(props)
end
function hook.rerender(newProps)
props = newProps or props
ReactRoblox.act(function()
root:render(React.createElement(Component, props))
end)
end
function hook.unmount()
ReactRoblox.act(function()
root:unmount()
end)
end
ReactRoblox.act(function()
hook.rerender(props)
end)
return hook
end
return renderHook

View file

@ -1,13 +0,0 @@
local suite = require("../suite")
local describe = suite.describe
local function test(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
test("useAtom")
return {}

View file

@ -1,40 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local Vide = require(ReplicatedStorage.Packages.Vide)
local VideCharm = require(ReplicatedStorage.Packages.VideCharm)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local root = Vide.root
local useAtom = VideCharm.useAtom
local test = suite.test
test("tracks atom state", function()
local state = atom(0)
local source
local cleanup = root(function()
source = useAtom(state)
end)
assert(source() == 0)
state(1)
assert(source() == 1)
cleanup()
end)
test("accepts a selector", function()
local state = atom(0)
local source
local cleanup = root(function()
source = useAtom(function()
return 2 * state()
end)
end)
assert(source() == 0)
state(1)
assert(source() == 2)
cleanup()
end)
return {}