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

@ -16,7 +16,7 @@ jobs:
run: sh bin/check.sh run: sh bin/check.sh
- name: Run tests - name: Run tests
run: sh bin/test.sh run: lute tests/init.luau
- name: Build models - name: Build models
run: sh bin/build.sh run: sh bin/build.sh

2
.gitignore vendored
View file

@ -1,7 +1,7 @@
/.vscode /.vscode
/build /build
/roblox_packages /roblox_packages
/packages/**/wally.lock /pkgs/**/wally.lock
/sourcemap.json /sourcemap.json
*.tsbuildinfo *.tsbuildinfo
*.rbxm *.rbxm

View file

@ -2,5 +2,9 @@
"languageMode": "strict", "languageMode": "strict",
"lint": { "lint": {
"LocalShadow": false "LocalShadow": false
},
"aliases": {
"tests": "tests",
"pkgs": "packages"
} }
} }

View file

@ -1,5 +1,5 @@
mkdir build mkdir build
rojo build packages/charm -o build/charm.rbxm rojo build pkgs/charm -o build/charm.rbxm
rojo build packages/charm-sync -o build/charm-sync.rbxm rojo build pkgs/charm-sync -o build/charm-sync.rbxm
rojo build packages/react-charm -o build/react-charm.rbxm rojo build pkgs/react-charm -o build/react-charm.rbxm
rojo build packages/vide-charm -o build/vide-charm.rbxm rojo build pkgs/vide-charm -o build/vide-charm.rbxm

View file

@ -1 +0,0 @@
lute tests/spec.server.luau

View file

@ -6,15 +6,26 @@
"$className": "ReplicatedStorage", "$className": "ReplicatedStorage",
"Packages": { "Packages": {
"$path": "roblox_packages", "$path": "roblox_packages",
"Charm": { "$path": "packages/charm" }, "Charm": { "$path": "pkgs/charm" },
"CharmSync": { "$path": "packages/charm-sync" }, "CharmSync": { "$path": "pkgs/charm-sync" },
"ReactCharm": { "$path": "packages/react-charm" }, "ReactCharm": { "$path": "pkgs/react-charm" },
"VideCharm": { "$path": "packages/vide-charm" } "VideCharm": { "$path": "pkgs/vide-charm" }
} }
}, },
"ServerScriptService": { "ServerScriptService": {
"$className": "ServerScriptService", "$className": "ServerScriptService",
"Tests": { "$path": "tests" } "Tests": {
"$path": "tests",
"Charm": { "$path": "pkgs/charm/__tests__" },
"CharmSync": { "$path": "pkgs/charm-sync/__tests__" },
"ReactCharm": { "$path": "pkgs/react-charm/__tests__" },
"VideCharm": { "$path": "pkgs/vide-charm/__tests__" }
},
"Benchmarks": {
"$className": "Folder",
"Charm": { "$path": "pkgs/charm/__benchmarks__" },
"CharmSync": { "$path": "pkgs/charm-sync/__benchmarks__" }
}
} }
} }
} }

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,54 +0,0 @@
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
local signal = signals.signal
local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking
local function atomOper<T>(source: (...T) -> T, equals: ((T, T) -> boolean)?, ...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
end
local transform = (...)
local currentState = (source :: any).value
local nextState: T
-- If the transform is a function, call it with the current state and pause
-- tracking to avoid adding signals called by the transform.
if type(transform) == "function" then
pauseTracking()
local success, result = pcall(transform, currentState)
resumeTracking()
if not success then
error(result, 2)
end
nextState = result
else
nextState = transform
end
if equals and equals(currentState, nextState) then
return nextState
end
source(nextState)
return nextState
end
local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
local source = signal(state)
local equals = options and options.equals
return function(...)
return atomOper(source, equals, ...)
end
end
return atom

View file

@ -1,27 +0,0 @@
local signals = require("./signals")
local effectImpl = signals.effect
local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
local disposed = false
local dispose: () -> ()
local function disposeSafe()
disposed = true
if dispose then
dispose()
end
end
dispose = effectImpl(function()
return callback(disposeSafe)
end)
if disposed then
dispose()
end
return disposeSafe
end
return effect

View file

@ -1,38 +0,0 @@
local listen = require("./listen")
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local cleanups: { [K]: () -> ()? } = {}
local unsub = listen(selector, function(currentItems, previousItems)
if not previousItems then
for key, value in currentItems do
cleanups[key] = observer(value, key)
end
return
end
for key in previousItems do
local cleanup = cleanups[key]
if currentItems[key] == nil and cleanup then
cleanups[key] = nil
cleanup()
end
end
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key)
end
end
end)
return function()
unsub()
for _, cleanup in cleanups do
(cleanup :: () -> ())()
end
table.clear(cleanups)
end
end
return observe

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.react)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.charm.src)

View file

@ -1,2 +0,0 @@
--!nocheck
return require(script.Parent.vide.src)

View file

@ -24,14 +24,18 @@ local function update(state)
state.users = table.clone(state.users) state.users = table.clone(state.users)
for user = 1, USERS do for user = 1, USERS do
if math.random() > 0.5 then local rng = math.random()
continue
end
state.users[user] = { if rng < 1 / 3 then
coins = math.random(1, 2), continue
gems = math.random(1, 2), elseif rng < 2 / 3 then
} state.users[user] = nil
else
state.users[user] = {
coins = math.random(1, 2),
gems = math.random(1, 2),
}
end
end end
return state return state

View file

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

View file

@ -6,17 +6,12 @@ local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local atom = Charm.atom local atom = Charm.atom
local cleanup: () -> ()
return { return {
AfterEach = function()
cleanup()
end,
Functions = { Functions = {
atom = function() atom = function()
local state = atom(0) local state = atom(0)
cleanup = effect(function() effect(function()
state() state()
end) end)
@ -32,7 +27,7 @@ return {
return 2 * state() return 2 * state()
end) end)
cleanup = effect(function() effect(function()
double() double()
end) end)

View file

@ -0,0 +1,63 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.Packages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local effect = Charm.effect
local listen = Charm.listen
local function propagate(w: number, h: number)
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
local function propagateListen(w: number, h: number)
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
listen(last, function() end)
end
src(src() + 1)
end
return {
Functions = {
["effect 1*100"] = function()
propagate(1, 100)
end,
["effect 100*1"] = function()
propagate(100, 1)
end,
["effect 10*10"] = function()
propagate(10, 10)
end,
["listen 1*100"] = function()
propagateListen(1, 100)
end,
["listen 100*1"] = function()
propagateListen(100, 1)
end,
["listen 10*10"] = function()
propagateListen(10, 10)
end,
},
}

View file

@ -1,13 +1,12 @@
local charm = require("../charm") local signals = require("../../src/signals")
local suite = require("../suite") local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed local computed = signals.computed
local signal = signals.signal local signal = signals.signal
local test = suite.test local test = suite.test
test("should correctly propagate changes through computed signals", function() test("should correctly propagate changes through computed signals", function()
local src = signal(0) local src, setSrc = signal(0)
local c1 = computed(function() local c1 = computed(function()
return src() % 2 return src() % 2
end) end)
@ -19,15 +18,15 @@ test("should correctly propagate changes through computed signals", function()
end) end)
c3() c3()
src(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
c2() -- c1 -> none, c2 -> none c2() -- c1 -> none, c2 -> none
src(3) -- c1 -> dirty, c2 -> toCheckDirty setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
assert(c3() == 1, "bad final value") assert(c3() == 1, "bad final value")
end) end)
test("should propagate updated source value through chained computations", function() test("should propagate updated source value through chained computations", function()
local src = signal(0) local src, setSrc = signal(0)
local a = computed(function() local a = computed(function()
return src() return src()
end) end)
@ -42,12 +41,12 @@ test("should propagate updated source value through chained computations", funct
end) end)
assert(d() == 0, "bad initial value") assert(d() == 0, "bad initial value")
src(2) setSrc(2)
assert(d() == 2, "bad updated value") assert(d() == 2, "bad updated value")
end) end)
test("should handle flags are indirectly updated during checkDirty", function() test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false) local a, setA = signal(false)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -61,22 +60,22 @@ test("should handle flags are indirectly updated during checkDirty", function()
end) end)
assert(d() == false, "bad initial value") assert(d() == false, "bad initial value")
a(true) setA(true)
assert(d() == true, "bad updated value") assert(d() == true, "bad updated value")
end) end)
test("should not update if the signal value is reverted", function() test("should not update if the signal value is reverted", function()
local times = 0 local times = 0
local src = signal(0) local src, setSrc = signal(0)
local c1 = computed(function() local c1 = computed(function()
times += 1 times += 1
return src() return src()
end) end)
c1() c1()
assert(times == 1, "computed did not run") assert(times == 1, "computed did not run")
src(1) setSrc(1)
src(0) setSrc(0)
c1() c1()
assert(times == 1, "computed ran when it should not") assert(times == 1, "computed ran when it should not")
end) end)

View file

@ -1,13 +1,13 @@
local charm = require("../charm") local signals = require("../../src/signals")
local suite = require("../suite") local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed local computed = signals.computed
local effect = signals.effect local effect = signals.effect
local effectScope = signals.effectScope local effectScope = signals.effectScope
local endBatch = signals.endBatch local endBatch = signals.endBatch
local pauseTracking = signals.pauseTracking local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking local resumeTracking = signals.resumeTracking
local onEffectCleanup = signals.onEffectCleanup
local signal = signals.signal local signal = signals.signal
local startBatch = signals.startBatch local startBatch = signals.startBatch
local test = suite.test local test = suite.test
@ -15,7 +15,7 @@ local test = suite.test
test("should clear subscriptions when untracked by all subscribers", function() test("should clear subscriptions when untracked by all subscribers", function()
local bRunTimes = 0 local bRunTimes = 0
local a = signal(1) local a, setA = signal(1)
local b = computed(function() local b = computed(function()
bRunTimes += 1 bRunTimes += 1
return a() * 2 return a() * 2
@ -25,15 +25,15 @@ test("should clear subscriptions when untracked by all subscribers", function()
end) end)
assert(bRunTimes == 1, "should run initially") assert(bRunTimes == 1, "should run initially")
a(2) setA(2)
assert(bRunTimes == 2, "should run after change") assert(bRunTimes == 2, "should run after change")
stopEffect() stopEffect()
a(3) setA(3)
assert(bRunTimes == 2, "should not run after stop") assert(bRunTimes == 2, "should not run after stop")
end) end)
test("should not run untracked inner effect", function() test("should not run untracked inner effect", function()
local a = signal(3) local a, setA = signal(3)
local b = computed(function() local b = computed(function()
return a() > 0 return a() > 0
end) end)
@ -48,14 +48,14 @@ test("should not run untracked inner effect", function()
end end
end) end)
a(2) setA(2)
a(1) setA(1)
a(0) setA(0)
end) end)
test("should run outer effect first", function() test("should run outer effect first", function()
local a = signal(1) local a, setA = signal(1)
local b = signal(1) local b, setB = signal(1)
effect(function() effect(function()
if a() > 0 then if a() > 0 then
@ -69,13 +69,13 @@ test("should run outer effect first", function()
end) end)
startBatch() startBatch()
b(0) setB(0)
a(0) setA(0)
endBatch() endBatch()
end) end)
test("should not trigger inner effect when resolve maybe dirty", function() test("should not trigger inner effect when resolve maybe dirty", function()
local a = signal(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
return a() % 2 return a() % 2
end) end)
@ -92,12 +92,12 @@ test("should not trigger inner effect when resolve maybe dirty", function()
end) end)
end) end)
a(2) setA(2)
end) end)
test("should trigger inner effects in sequence", function() test("should trigger inner effects in sequence", function()
local a = signal(0) local a, setA = signal(0)
local b = signal(0) local b, setB = signal(0)
local c = computed(function() local c = computed(function()
return a() - b() return a() - b()
end) end)
@ -121,16 +121,16 @@ test("should trigger inner effects in sequence", function()
table.clear(order) table.clear(order)
startBatch() startBatch()
b(1) setB(1)
a(1) setA(1)
endBatch() endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
end) end)
test("should trigger inner effects in sequence in effect scope", function() test("should trigger inner effects in sequence in effect scope", function()
local a = signal(0) local a, setA = signal(0)
local b = signal(0) local b, setB = signal(0)
local order: { string } = {} local order: { string } = {}
effectScope(function() effectScope(function()
@ -149,8 +149,8 @@ test("should trigger inner effects in sequence in effect scope", function()
table.clear(order) table.clear(order)
startBatch() startBatch()
b(1) setB(1)
a(1) setA(1)
endBatch() endBatch()
assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`) assert(table.concat(order, ", ") == "first inner, last inner", `wrong order: {table.concat(order, ", ")}`)
@ -170,12 +170,12 @@ test("should custom effect support batch", function()
local logs: { string } = {} local logs: { string } = {}
local a = signal(0) local a = signal(0)
local b = signal(0) local b, setB = signal(0)
local aa = computed(function() local aa = computed(function()
table.insert(logs, "aa-0") table.insert(logs, "aa-0")
if a() == 0 then if a() == 0 then
b(1) setB(1)
end end
table.insert(logs, "aa-1") table.insert(logs, "aa-1")
return nil return nil
@ -197,8 +197,8 @@ test("should custom effect support batch", function()
end) end)
test("should duplicate subscribers do not affect the notify order", function() test("should duplicate subscribers do not affect the notify order", function()
local src1 = signal(0) local src1, setSrc1 = signal(0)
local src2 = signal(0) local src2, setSrc2 = signal(0)
local order: { string } = {} local order: { string } = {}
effect(function() effect(function()
@ -216,17 +216,17 @@ test("should duplicate subscribers do not affect the notify order", function()
table.insert(order, "b") table.insert(order, "b")
src1() src1()
end) end)
src2(1) -- src1.subs: a -> b -> a setSrc2(1) -- src1.subs: a -> b -> a
table.clear(order) table.clear(order)
src1(src1() + 1) setSrc1(src1() + 1)
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
end) end)
test("should handle side effect with inner effects", function() test("should handle side effect with inner effects", function()
local a = signal(0) local a, setA = signal(0)
local b = signal(0) local b, setB = signal(0)
local order: { string } = {} local order: { string } = {}
effect(function() effect(function()
@ -241,14 +241,14 @@ test("should handle side effect with inner effects", function()
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`) assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
table.clear(order) table.clear(order)
b(1) setB(1)
a(1) setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`) assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end) end)
end) end)
test("should handle flags are indirectly updated during checkDirty", function() test("should handle flags are indirectly updated during checkDirty", function()
local a = signal(false) local a, setA = signal(false)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -268,71 +268,57 @@ test("should handle flags are indirectly updated during checkDirty", function()
triggers += 1 triggers += 1
end) end)
assert(triggers == 1, "did not run initially") assert(triggers == 1, "did not run initially")
a(true) setA(true)
assert(triggers == 2, "did not update indirectly") assert(triggers == 2, "did not update indirectly")
end) end)
test("should run effect cleanup", function() test("should run cleanups in sequence", function()
local cleanups = 0 local order = {}
local a = signal(false) local a, setA = signal(false)
local cleanup = effect(function() local stopEffect = effect(function()
a() a()
return function() onEffectCleanup(function()
cleanups += 1 table.insert(order, "a")
end end)
end) onEffectCleanup(function()
table.insert(order, "b")
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)
end) end)
assert(cleanups == 0, "should not cleanup immediately") assert(table.concat(order) == "", "should not cleanup immediately")
a(true)
assert(cleanups == 1, "should cleanup once") setA(true)
stopScope() assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
assert(cleanups == 2, "should cleanup after outer dispose")
a(false) table.clear(order)
assert(cleanups == 2, "should not cleanup after update") stopEffect()
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
table.clear(order)
setA(false)
assert(table.concat(order) == "", "should not cleanup after update")
end) end)
test("should run inner effect cleanup", function() test("should run inner effect cleanup", function()
local cleanups = 0 local cleanups = 0
local a = signal(false) local a, setA = signal(false)
local stopScope = effect(function() local stopEffect = effect(function()
a() a()
effect(function() effect(function()
return function() onEffectCleanup(function()
cleanups += 1 cleanups += 1
end end)
end) end)
end) end)
assert(cleanups == 0, "should not cleanup immediately") assert(cleanups == 0, "should not cleanup immediately")
a(true) setA(true)
assert(cleanups == 1, "should cleanup after outer update") assert(cleanups == 1, "should cleanup after outer update")
stopScope() stopEffect()
assert(cleanups == 2, "should cleanup after outer dispose") assert(cleanups == 2, "should cleanup after outer dispose")
a(false) setA(false)
assert(cleanups == 2, "should not cleanup after update") assert(cleanups == 2, "should not cleanup after update")
end) end)

View file

@ -1,14 +1,15 @@
local charm = require("../charm") local signals = require("../../src/signals")
local suite = require("../suite") local suite = require("@tests/suite")
local signals = charm.signals
local effect = signals.effect local effect = signals.effect
local effectScope = signals.effectScope local effectScope = signals.effectScope
local signal = signals.signal local signal = signals.signal
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local test = suite.test local test = suite.test
test("should not trigger after stop", function() test("should not trigger after stop", function()
local count = signal(1) local count, setCount = signal(1)
local triggers = 0 local triggers = 0
@ -19,42 +20,26 @@ test("should not trigger after stop", function()
end) end)
assert(triggers == 1, "did not run initially") assert(triggers == 1, "did not run initially")
count(2) setCount(2)
assert(triggers == 2, "did not run after update") assert(triggers == 2, "did not run after update")
end) end)
count(3) setCount(3)
assert(triggers == 3, "did not run after update outside scope") assert(triggers == 3, "did not run after update outside scope")
stopScope() stopScope()
count(4) setCount(4)
assert(triggers == 3, "ran after stop") assert(triggers == 3, "ran after stop")
end) end)
test("should run effect cleanup", function() test("should run indirect scope 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 cleanups = 0
local cleanup local cleanup
local stopScope = effectScope(function() local stopScope = effectScope(function()
cleanup = effectScope(function() cleanup = effectScope(function()
return function() onScopeDispose(function()
cleanups += 1 cleanups += 1
end end)
end) end)
end) end)
@ -65,4 +50,26 @@ test("should run indirect effect cleanup", function()
assert(cleanups == 1, "should skip redundant dispose") assert(cleanups == 1, "should skip redundant dispose")
end) end)
test("should run indirect effect cleanup", function()
local cleanups = 0
local a, setA = signal(false)
local stopScope = effectScope(function()
effect(function()
a()
onEffectCleanup(function()
cleanups += 1
end)
end)
end)
assert(cleanups == 0, "should not cleanup immediately")
setA(true)
assert(cleanups == 1, "should cleanup once")
stopScope()
assert(cleanups == 2, "should cleanup after outer dispose")
setA(false)
assert(cleanups == 2, "should not cleanup after update")
end)
return {} return {}

View file

@ -1,7 +1,6 @@
local charm = require("../charm") local signals = require("../../src/signals")
local suite = require("../suite") local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed local computed = signals.computed
local effect = signals.effect local effect = signals.effect
local pauseTracking = signals.pauseTracking local pauseTracking = signals.pauseTracking
@ -92,7 +91,7 @@ local function reaction<T>(
end end
test("#48", function() test("#48", function()
local source = signal(0) local source, setSource = signal(0)
local disposeInner: () -> () local disposeInner: () -> ()
reaction(function() reaction(function()
@ -107,9 +106,9 @@ test("#48", function()
end end
end) end)
source(1) setSource(1)
source(2) setSource(2)
source(3) setSource(3)
end) end)
return {} return {}

View file

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

View file

@ -1,7 +1,6 @@
local charm = require("../charm") local signals = require("../../src/signals")
local suite = require("../suite") local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed local computed = signals.computed
local effect = signals.effect local effect = signals.effect
local signal = signals.signal local signal = signals.signal
@ -44,7 +43,7 @@ describe("graph updates", function()
-- C -- C
-- | -- |
-- D -- D
local a = signal(2) local a, setA = signal(2)
local b = computed(function() local b = computed(function()
return a() - 1 return a() - 1
@ -65,7 +64,7 @@ describe("graph updates", function()
assert(computeCalls == 1, "compute should be called once") assert(computeCalls == 1, "compute should be called once")
computeCalls = 0 computeCalls = 0
a(4) setA(4)
d() d()
assert(computeCalls == 1, "compute should drop update") assert(computeCalls == 1, "compute should drop update")
end) end)
@ -79,7 +78,7 @@ describe("graph updates", function()
-- \ / -- \ /
-- D -- D
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -97,7 +96,7 @@ describe("graph updates", function()
assert(d() == "a a", "d should be 'a a'") assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once") assert(spyCalls == 1, "compute should be called once")
a("aa") setA("aa")
assert(d() == "aa aa", "d should be 'aa aa'") assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice") assert(spyCalls == 2, "compute should be called only twice")
end) end)
@ -112,7 +111,7 @@ describe("graph updates", function()
-- | -- |
-- E -- E
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -134,7 +133,7 @@ describe("graph updates", function()
assert(e() == "a a", "e should be 'a a'") assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once") assert(spyCalls == 1, "compute should be called once")
a("aa") setA("aa")
assert(e() == "aa aa", "e should be 'aa aa'") assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice") assert(spyCalls == 2, "compute should be called only twice")
end) end)
@ -142,7 +141,7 @@ describe("graph updates", function()
test("should bail out if result is the same", function() test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes -- Bail out if value of "B" never changes
-- A->B->C -- A->B->C
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
a() a()
return "foo" return "foo"
@ -158,7 +157,7 @@ describe("graph updates", function()
assert(c() == "foo", "c should be 'foo'") assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once") assert(spyCalls == 1, "compute should be called once")
a("aa") setA("aa")
assert(c() == "foo", "c should be 'foo'") assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out") assert(spyCalls == 1, "compute should bail out")
end) end)
@ -174,7 +173,7 @@ describe("graph updates", function()
-- E -- E
-- / \ -- / \
-- F G -- F G
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
@ -223,7 +222,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0 fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0
a("b") setA("b")
assert(e() == "b b", "e should be 'b b'") assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update") assert(eSpyCalls == 1, "e should run once after update")
@ -238,7 +237,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0 fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0
a("c") setA("c")
assert(e() == "c c", "e should be 'c c'") assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update") assert(eSpyCalls == 1, "e should run once after update")
@ -259,7 +258,7 @@ describe("graph updates", function()
-- *A -- *A
-- / \ -- / \
-- *B C <- we don't listen to C -- *B C <- we don't listen to C
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
@ -274,7 +273,7 @@ describe("graph updates", function()
assert(b() == "a", "b should be 'a'") assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run") assert(spyCalls == 0, "compute should not run")
a("aa") setA("aa")
assert(b() == "aa", "b should be 'aa'") assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run") assert(spyCalls == 0, "compute should not run")
end) end)
@ -288,7 +287,7 @@ describe("graph updates", function()
-- *B D <- we don't listen to C -- *B D <- we don't listen to C
-- | -- |
-- *C -- *C
local a = signal("a") local a, setA = signal("a")
local spyBCalls = 0 local spyBCalls = 0
local spyB = function() local spyB = function()
spyBCalls += 1 spyBCalls += 1
@ -319,7 +318,7 @@ describe("graph updates", function()
spyCCalls = 0 spyCCalls = 0
unsub() unsub()
a("aa") setA("aa")
assert(spyBCalls == 0, "b should not run") assert(spyBCalls == 0, "b should not run")
assert(spyCCalls == 0, "c should not run") assert(spyCCalls == 0, "c should not run")
@ -336,7 +335,7 @@ describe("graph updates", function()
-- B *C <- returns same value every time -- B *C <- returns same value every time
-- \ / -- \ /
-- D -- D
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -354,7 +353,7 @@ describe("graph updates", function()
assert(d() == "a c", "d should be 'a c'") assert(d() == "a c", "d should be 'a c'")
spyResult = nil spyResult = nil
a("aa") setA("aa")
d() d()
assert(spyResult == "aa c", "d should have returned 'aa c'") assert(spyResult == "aa c", "d should have returned 'aa c'")
end) end)
@ -368,7 +367,7 @@ describe("graph updates", function()
-- B *C *D -- B *C *D
-- \ | / -- \ | /
-- E -- E
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -390,13 +389,13 @@ describe("graph updates", function()
assert(e() == "a c d", "e should be 'a c d'") assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil spyResult = nil
a("aa") setA("aa")
e() e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'") assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end) end)
test("should support lazy branches", function() test("should support lazy branches", function()
local a = signal(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -405,10 +404,10 @@ describe("graph updates", function()
end) end)
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
a(1) setA(1)
assert(c() == 1, "c should be 1") assert(c() == 1, "c should be 1")
a(0) setA(0)
assert(c() == 0, "c should be 0 again") assert(c() == 0, "c should be 0 again")
end) end)
@ -420,7 +419,7 @@ describe("graph updates", function()
-- *B *C -- *B *C
-- \ / -- \ /
-- D -- D
local a = signal("a") local a, setA = signal("a")
local b = computed(function() local b = computed(function()
a() a()
return "b" return "b"
@ -439,14 +438,14 @@ describe("graph updates", function()
assert(d() == "b c", "d should be 'b c'") assert(d() == "b c", "d should be 'b c'")
spyCalls = 0 spyCalls = 0
a("aa") setA("aa")
assert(spyCalls == 0, "d should not run") assert(spyCalls == 0, "d should not run")
end) end)
end) end)
describe("error handling", function() describe("error handling", function()
test("should keep graph consistent on errors during activation", function() test("should keep graph consistent on errors during activation", function()
local a = signal(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
error("fail") error("fail")
end) end)
@ -460,12 +459,12 @@ describe("error handling", function()
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(result, "fail"), "should throw fail")
a(1) setA(1)
assert(c() == 1, "c should recover and return 1") assert(c() == 1, "c should recover and return 1")
end) end)
test("should keep graph consistent on errors in computeds", function() test("should keep graph consistent on errors in computeds", function()
local a = signal(0) local a, setA = signal(0)
local b = computed(function() local b = computed(function()
if a() == 1 then if a() == 1 then
error("fail") error("fail")
@ -478,14 +477,14 @@ describe("error handling", function()
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
a(1) setA(1)
local success, result = pcall(function() local success, result = pcall(function()
b() b()
end) end)
assert(not success, "should throw") assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail") assert(string.match(result, "fail"), "should throw fail")
a(2) setA(2)
assert(c() == 2, "c should recover and return 2") assert(c() == 2, "c should recover and return 2")
end) end)
end) end)

View file

@ -1,7 +1,6 @@
local charm = require("../charm") local signals = require("../../src/signals")
local suite = require("../suite") local suite = require("@tests/suite")
local signals = charm.signals
local computed = signals.computed local computed = signals.computed
local pauseTracking = signals.pauseTracking local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking local resumeTracking = signals.resumeTracking
@ -9,7 +8,7 @@ local signal = signals.signal
local test = suite.test local test = suite.test
test("should pause tracking", function() test("should pause tracking", function()
local src = signal(0) local src, setSrc = signal(0)
local c = computed(function() local c = computed(function()
pauseTracking() pauseTracking()
local value = src() local value = src()
@ -18,7 +17,7 @@ test("should pause tracking", function()
end) end)
assert(c() == 0, "should not track src") assert(c() == 0, "should not track src")
src(1) setSrc(1)
assert(c() == 0, "should not track src") assert(c() == 0, "should not track src")
end) end)

View file

@ -0,0 +1,20 @@
local suite = require("@tests/suite")
local describe = suite.describe
local function collect(name: string)
describe(name, function()
return require(`./{name}/spec`) :: any
end)
end
local function spec(name: string)
describe(name, function()
return require(`./{name}.spec`) :: any
end)
end
collect("signals")
spec("subscribe")
return {}

View file

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

54
pkgs/charm/src/atom.luau Normal file
View file

@ -0,0 +1,54 @@
local signals = require("./signals")
local types = require("./types")
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
local signal = signals.signal
local function atomGetterSetterEquals<T>(
source: () -> T,
setSource: (((T) -> T) | T) -> T,
equals: (T, T) -> boolean
): Atom<T>
return function(...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
end
local nextState = (...)
if type(nextState) == "function" then
return setSource(function(currentState): T
local nextState = nextState(currentState)
return if equals(currentState, nextState) then currentState else nextState
end)
else
return setSource(function(currentState): T
return if equals(currentState, nextState) then currentState else nextState
end)
end
end
end
local function atomGetterSetter<T>(source: () -> T, setSource: (((T) -> T) | T) -> T): Atom<T>
return function(...: ((T) -> T) | T): T
if select("#", ...) == 0 then
return source()
else
return setSource((...))
end
end
end
local function atom<T>(state: T, equals: ((previous: T, current: T) -> boolean)?): Atom<T>
local source, setSource = signal(state)
if equals then
return atomGetterSetterEquals(source, setSource, equals)
else
return atomGetterSetter(source, setSource)
end
end
return atom

View file

@ -0,0 +1,27 @@
local signals = require("./signals")
local _effect = signals.effect
local onEffectCleanup = signals.onEffectCleanup
local function effect(callback: (dispose: () -> ()) -> ...() -> ()?): () -> ()
local disposeImmediately = false
local function dispose()
disposeImmediately = true
end
dispose = _effect(function()
local cleanup = callback(dispose)
if cleanup then
onEffectCleanup(cleanup)
end
end)
if disposeImmediately then
dispose()
end
return dispose
end
return effect

View file

@ -0,0 +1,15 @@
local signals = require("./signals")
local _effectScope = signals.effectScope
local onScopeDispose = signals.onScopeDispose
local function effectScope(callback: () -> ...() -> ()?): () -> ()
return _effectScope(function()
local cleanup = callback()
if cleanup then
onScopeDispose(cleanup)
end
end)
end
return effectScope

View file

@ -15,6 +15,8 @@ export type Selector<T = any> = types.Selector<T>
local computed = signals.computed local computed = signals.computed
local effectScope = signals.effectScope local effectScope = signals.effectScope
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
return { return {
atom = atom, atom = atom,
@ -27,5 +29,7 @@ return {
observe = observe, observe = observe,
subscribe = subscribe, subscribe = subscribe,
untracked = untracked, untracked = untracked,
onEffectCleanup = onEffectCleanup,
onScopeDispose = onScopeDispose,
signals = signals, signals = signals,
} }

View file

@ -6,40 +6,31 @@ local pauseTracking = signals.pauseTracking
local resumeTracking = signals.resumeTracking local resumeTracking = signals.resumeTracking
local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> () local function listen<T>(selector: () -> T, listener: (state: T, previous: T?, dispose: () -> ()) -> ()): () -> ()
local disposed = false local disposeImmediately = false
local mounted = true
local current: T? local current: T?
local dispose: () -> ()
local function disposeSafe() local function dispose()
disposed = true disposeImmediately = true
if dispose then
dispose()
end
end end
dispose = effect(function() dispose = effect(function()
local value = selector() local value = selector()
if mounted then if value ~= current then
mounted = false local previous = current
elseif value == current then current = value
return
pauseTracking()
spawn(listener, value, previous, dispose)
resumeTracking()
end end
local previous = current
current = value
pauseTracking()
spawn(listener, value, previous, disposeSafe)
resumeTracking()
end) end)
if disposed then if disposeImmediately then
dispose() dispose()
end end
return disposeSafe return dispose
end end
return listen return listen

View file

@ -19,7 +19,7 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, transform: (
resumeTracking() resumeTracking()
if not success then if not success then
error(`observe() transform function failed: {valueOut}`) error(`mapped() transform function failed: {valueOut}`)
end end
return valueOut, keyOut return valueOut, keyOut
@ -47,6 +47,10 @@ local function mapped<KI, VI, KO, VO>(selector: () -> { [KI]: VI }, transform: (
return mappedItems return mappedItems
end end
if previousItems == currentItems then
return previousMappedItems
end
local mappedItems = table.clone(previousMappedItems) local mappedItems = table.clone(previousMappedItems)
-- Update and remove mapped keys if the source values changed -- Update and remove mapped keys if the source values changed

View file

@ -0,0 +1,60 @@
local signals = require("./signals")
local computed = signals.computed
local effect = signals.effect
local getCurrentSub = signals.getCurrentSub
local onEffectCleanup = signals.onEffectCleanup
local onScopeDispose = signals.onScopeDispose
local function noop() end
local function observe<K, V>(selector: () -> { [K]: V }, observer: (item: V, key: K) -> ...() -> ()?): () -> ()
local tracked = computed(function()
return selector()
end)
local cleanups: { [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()
end
end
for key, value in currentItems do
if previousItems[key] == nil then
cleanups[key] = observer(value, key) or noop
end
end
end
local function teardown()
for _, cleanup in cleanups do
cleanup()
end
table.clear(cleanups)
end
local disposeEffect = effect(function()
local previousItems = currentItems
currentItems = tracked()
update(currentItems, previousItems)
end)
if getCurrentSub() then
onEffectCleanup(teardown)
else
onScopeDispose(teardown)
end
return function()
disposeEffect()
teardown()
end
end
return observe

View file

@ -1,5 +1,5 @@
--[=[ --[=[
alien-signals 2.0.1 implementation with effect cleanups, adopted from alien-signals 2.0.1 implementation adopted from
https://github.com/Nicell/alien-signals-luau https://github.com/Nicell/alien-signals-luau
https://github.com/stackblitz/alien-signals https://github.com/stackblitz/alien-signals
@ -47,7 +47,7 @@ type EffectScope = ReactiveNode & {
} }
type Effect = ReactiveNode & { type Effect = ReactiveNode & {
fn: () -> (() -> ())?, fn: () -> (),
cleanup: (() -> ())?, cleanup: (() -> ())?,
} }
@ -100,26 +100,36 @@ local function setCurrentScope(scope: EffectScope?)
return prevScope return prevScope
end end
local function pauseTracking() local function addCleanup(e: EffectScope, cleanup: () -> ())
pauseStackCount += 1 local prevCleanup = e.cleanup
pauseStack[pauseStackCount] = activeSub if prevCleanup then
activeSub = nil e.cleanup = function()
prevCleanup()
cleanup()
end
else
e.cleanup = cleanup
end
end end
local function resumeTracking() local function onEffectCleanup(cleanup: () -> ())
activeSub = pauseStack[pauseStackCount] if activeSub then
pauseStack[pauseStackCount] = nil addCleanup(activeSub :: Effect, cleanup)
pauseStackCount -= 1 end
end end
local function cleanup(e: ReactiveNode) local function onScopeDispose(cleanup: () -> ())
local e = e :: EffectScope if activeScope then
local cleanup = e.cleanup addCleanup(activeScope, cleanup)
if cleanup then end
e.cleanup = nil end
local function cleanupEffect(e: EffectScope)
if e.cleanup then
local prev = setCurrentSub(nil) local prev = setCurrentSub(nil)
local success, result: unknown = pcall(cleanup) local success, result: unknown = pcall(e.cleanup)
setCurrentSub(prev) setCurrentSub(prev)
e.cleanup = nil
if not success then if not success then
error(result) error(result)
end end
@ -128,16 +138,15 @@ end
local function run(e: EffectScope, flags: ReactiveFlags): () local function run(e: EffectScope, flags: ReactiveFlags): ()
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(e.deps :: Link, e)) then
cleanup(e) cleanupEffect(e)
local prev = setCurrentSub(e) local prev = setCurrentSub(e)
startTracking(e) startTracking(e)
local success, result = pcall((e :: Effect).fn) local success, result: unknown = pcall((e :: Effect).fn)
setCurrentSub(prev) setCurrentSub(prev)
endTracking(e) endTracking(e)
if not success then if not success then
error(result) error(result)
end end
e.cleanup = result
return return
elseif bit32.band(flags, PENDING) ~= 0 then elseif bit32.band(flags, PENDING) ~= 0 then
e.flags = bit32.band(flags, bit32.bnot(PENDING)) e.flags = bit32.band(flags, bit32.bnot(PENDING))
@ -182,6 +191,18 @@ local function endBatch()
end end
end end
local function pauseTracking()
pauseStackCount += 1
pauseStack[pauseStackCount] = activeSub
activeSub = nil
end
local function resumeTracking()
activeSub = pauseStack[pauseStackCount]
pauseStack[pauseStackCount] = nil
pauseStackCount -= 1
end
local function updateComputed(c: Computed): boolean local function updateComputed(c: Computed): boolean
local prevSub = setCurrentSub(c) local prevSub = setCurrentSub(c)
startTracking(c) startTracking(c)
@ -210,22 +231,8 @@ local function updateSignal(s: Signal, value: any): boolean
end end
end end
local function signalOper<T>(this: Signal<T>, ...: T): T? local function signalGetterSetter<T>(this: Signal<T>): (() -> T, (value: ((T) -> T) | T) -> T)
if select("#", ...) > 0 then local function getter(): T
local newValue = (...)
if this.value ~= newValue then
this.value = newValue
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
end
end
return
else
local value = this.value local value = this.value
if bit32.band(this.flags, DIRTY) ~= 0 then if bit32.band(this.flags, DIRTY) ~= 0 then
if updateSignal(this, value) then if updateSignal(this, value) then
@ -240,64 +247,93 @@ local function signalOper<T>(this: Signal<T>, ...: T): T?
end end
return value return value
end end
end
local function computedOper<T>(this: Computed<T>): T local function setter(newValue: ((T) -> T) | T): T
local flags = this.flags local value = this.value
if bit32.band(flags, DIRTY) ~= 0 or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this)) then local newValue = newValue :: T
if updateComputed(this) then if type(newValue) == "function" then
local prev = setCurrentSub(nil)
local success, result = pcall(newValue, value)
setCurrentSub(prev)
if not success then
error(result)
end
newValue = result
end
if value ~= newValue then
this.value = newValue
this.flags = bit32.bor(MUTABLE, DIRTY)
local subs = this.subs local subs = this.subs
if subs then if subs then
shallowPropagate(subs) propagate(subs)
if batchDepth == 0 then
flush()
end
end end
end end
elseif bit32.band(flags, PENDING) ~= 0 then return newValue
this.flags = bit32.band(flags, bit32.bnot(PENDING))
end end
if activeSub then
link(this, activeSub) return getter, setter
elseif activeScope then
link(this, activeScope)
end
return this.value :: T
end end
local function effectOper(this: EffectScope): () local function computedGetter<T>(this: Computed<T>): () -> T
local dep = this.deps return function()
while dep do local flags = this.flags
dep = unlink(dep, this) if
bit32.band(flags, DIRTY) ~= 0
or (bit32.band(flags, PENDING) ~= 0 and checkDirty(this.deps :: Link, this))
then
if updateComputed(this) then
local subs = this.subs
if subs then
shallowPropagate(subs)
end
end
elseif bit32.band(flags, PENDING) ~= 0 then
this.flags = bit32.band(flags, bit32.bnot(PENDING))
end
if activeSub then
link(this, activeSub)
elseif activeScope then
link(this, activeScope)
end
return this.value :: T
end end
local sub = this.subs
while sub do
unlink(sub)
sub = this.subs
end
this.flags = NONE
cleanup(this)
end end
local signalMetatable = { __call = signalOper } local function effectStop(this: EffectScope): () -> ()
local computedMetatable = { __call = computedOper } return function()
local effectMetatable = { __call = effectOper } local dep = this.deps
while dep do
dep = unlink(dep, this)
end
local sub = this.subs
while sub do
unlink(sub)
sub = this.subs
end
this.flags = NONE
cleanupEffect(this)
end
end
local function signal<T>(initialValue: T): ((T) -> ()) & (() -> T) local function signal<T>(initialValue: T): (() -> T, (value: ((T) -> T) | T) -> T)
local this: Signal<T> = { return signalGetterSetter({
previousValue = initialValue, previousValue = initialValue,
value = initialValue, value = initialValue,
flags = MUTABLE, flags = MUTABLE,
} })
return setmetatable(this, signalMetatable) :: any
end end
local function computed<T>(getter: (previousValue: T?) -> T): () -> T local function computed<T>(getter: (previousValue: T?) -> T): () -> T
local this: Computed<T> = { return computedGetter({
flags = bit32.bor(MUTABLE, DIRTY), flags = bit32.bor(MUTABLE, DIRTY),
getter = getter, getter = getter,
} })
return setmetatable(this, computedMetatable) :: any
end end
local function effect(fn: () -> ...() -> ()?): () -> () local function effect(fn: () -> ()): () -> ()
local e: Effect = { local e: Effect = {
fn = fn, fn = fn,
flags = WATCHING, flags = WATCHING,
@ -308,16 +344,15 @@ local function effect(fn: () -> ...() -> ()?): () -> ()
link(e, activeScope) link(e, activeScope)
end end
local prev = setCurrentSub(e) local prev = setCurrentSub(e)
local success, result = pcall(fn) local success, result: unknown = pcall(fn)
setCurrentSub(prev) setCurrentSub(prev)
if not success then if not success then
error(result) error(result)
end end
e.cleanup = result return effectStop(e)
return setmetatable(e, effectMetatable) :: any
end end
local function effectScope(fn: () -> ...() -> ()?): () -> () local function effectScope(fn: () -> ()): () -> ()
local e: EffectScope = { local e: EffectScope = {
flags = NONE, flags = NONE,
} }
@ -325,13 +360,12 @@ local function effectScope(fn: () -> ...() -> ()?): () -> ()
link(e, activeScope) link(e, activeScope)
end end
local prev = setCurrentScope(e) local prev = setCurrentScope(e)
local success, result = pcall(fn) local success, result: unknown = pcall(fn)
setCurrentScope(prev) setCurrentScope(prev)
if not success then if not success then
error(result) error(result)
end end
e.cleanup = result return effectStop(e)
return setmetatable(e, effectMetatable) :: any
end end
local function notify(e: ReactiveNode) local function notify(e: ReactiveNode)
@ -343,7 +377,7 @@ local function notify(e: ReactiveNode)
notify(subs.sub :: Effect | EffectScope) notify(subs.sub :: Effect | EffectScope)
else else
queuedEffectsLength += 1 queuedEffectsLength += 1
queuedEffects[queuedEffectsLength] = e :: Effect | EffectScope queuedEffects[queuedEffectsLength] = e :: Effect
end end
end end
end end
@ -364,7 +398,7 @@ local function unwatched(signal: ReactiveNode)
until not toRemove until not toRemove
signal.flags = bit32.bor(signal.flags, DIRTY) signal.flags = bit32.bor(signal.flags, DIRTY)
end end
cleanup(signal) cleanupEffect(signal :: EffectScope)
end end
local reactiveSystem = createReactiveSystem({ local reactiveSystem = createReactiveSystem({
@ -393,6 +427,8 @@ return {
endBatch = endBatch, endBatch = endBatch,
pauseTracking = pauseTracking, pauseTracking = pauseTracking,
resumeTracking = resumeTracking, resumeTracking = resumeTracking,
onEffectCleanup = onEffectCleanup,
onScopeDispose = onScopeDispose,
signal = signal, signal = signal,
computed = computed, computed = computed,
effect = effect, effect = effect,

View file

@ -8,9 +8,8 @@ local resumeTracking = signals.resumeTracking
local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> () local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
local current: T local current: T
local mounted = true local mounted = true
local dispose: () -> ()
dispose = effect(function() return effect(function()
local value = selector() local value = selector()
if mounted then if mounted then
@ -25,10 +24,6 @@ local function subscribe<T>(selector: () -> T, listener: (T, T) -> ()): () -> ()
resumeTracking() resumeTracking()
end end
end) end)
return function()
dispose()
end
end end
return subscribe return subscribe

View file

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

View file

@ -3,7 +3,7 @@ local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm) local Charm = require(ReplicatedStorage.Packages.Charm)
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm) local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local renderHook = require(ServerScriptService.Tests.utils.renderHook) local renderHook = require(script.Parent.helpers.renderHook)
local suite = require(ServerScriptService.Tests.suite) local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom local atom = Charm.atom

View file

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

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,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,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,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

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