refactor: rename batch, misc fixes & perf updates

This commit is contained in:
littensy 2025-11-04 21:39:28 -08:00
parent 2aa7bd3e15
commit 7bb6edb403
16 changed files with 214 additions and 258 deletions

View file

@ -44,7 +44,7 @@
- [`effect(callback)`](#effectcallback) - [`effect(callback)`](#effectcallback)
- [`untracked(callback)`](#untrackedcallback) - [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback) - [`peek(callback)`](#peekcallback)
- [`batched(callback)`](#batchedcallback) - [`batch(callback)`](#batchcallback)
- [`effectScope(callback)`](#effectscopecallback) - [`effectScope(callback)`](#effectscopecallback)
- [`listen(getter, callback)`](#listengetter-callback) - [`listen(getter, callback)`](#listengetter-callback)
- [`subscribe(getter, callback)`](#subscribegetter-callback) - [`subscribe(getter, callback)`](#subscribegetter-callback)
@ -334,7 +334,7 @@ disposeOuter() --> Cleaned up inner effect
--- ---
### `batched(callback)` ### `batch(callback)`
Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes. Combines multiple signal updates made by the callback into a single commit that triggers effects once the callback completes.
@ -348,7 +348,7 @@ end)
-- Combines both writes into a single update. -- Combines both writes into a single update.
-- Once the callback completes, outputs "Full name: Foo Bar" -- Once the callback completes, outputs "Full name: Foo Bar"
batched(function() batch(function()
setName("Foo") setName("Foo")
setSurname("Bar") setSurname("Bar")
end) end)
@ -542,13 +542,15 @@ max(-1) --> 1
### `globals` ### `globals`
Global flags that customize the behavior of Charm. The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio. Global flags that customize the behavior of Charm.
| Flag | Default | Description | The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio.
| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. | | Flag | Default | Description |
| frozen | `true` in Studio | Enforces immutability by deep-freezing tables passed to signals. | | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. | | strict | `true`/`false` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
| frozen | `true`/`false` | Enforces data immutability by deep-freezing tables passed to signals. |
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. |
--- ---
@ -812,7 +814,6 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
**What to look out for:** **What to look out for:**
1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like: 1. Address all of the type errors introduced in your project after updating Charm. Most of them are caused by changes like:
- `batch()` was renamed to `batched()`
- The second arguments of `atom()` changed from an `options` table to an equality function - The second arguments of `atom()` changed from an `options` table to an equality function
- Removed the second argument of `computed()` (you can do your own equality checks now) - Removed the second argument of `computed()` (you can do your own equality checks now)
- Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`) - Removed the cleanup argument in effect callbacks (`effect(function(cleanup) end)`)

View file

@ -65,9 +65,9 @@ end
local function hydrate(payloads: { SyncPayload }) local function hydrate(payloads: { SyncPayload })
for _, payload in payloads do for _, payload in payloads do
if payload.type == "init" then if payload.type == "init" then
Charm.batched(applyInitPayload, payload.data) Charm.batch(applyInitPayload, payload.data)
elseif payload.type == "patch" then elseif payload.type == "patch" then
Charm.batched(applyPatchPayload, payload.data) Charm.batch(applyPatchPayload, payload.data)
end end
end end
end end

View file

@ -1,5 +1,5 @@
local core = require("../../core") local core = require("../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../test/suite")
local computed = core.computed local computed = core.computed
local effect = core.effect local effect = core.effect

View file

@ -1,5 +1,5 @@
local core = require("../../core") local core = require("../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../test/suite")
local computed = core.computed local computed = core.computed
local effect = core.effect local effect = core.effect

View file

@ -1,5 +1,5 @@
local core = require("../../core") local core = require("../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../test/suite")
local effect = core.effect local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope

View file

@ -1,5 +1,5 @@
local core = require("../../core") local core = require("../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../test/suite")
local atom = core.atom local atom = core.atom
local computed = core.computed local computed = core.computed

View file

@ -4,6 +4,7 @@ local suite = require("../../../../test/suite")
local signal = Charm.signal local signal = Charm.signal
local computed = Charm.computed local computed = Charm.computed
local effect = Charm.effect local effect = Charm.effect
local effectScope = Charm.effectScope
local observe = Charm.observe local observe = Charm.observe
local test = suite.test local test = suite.test
@ -42,48 +43,44 @@ test("should dispose scopes", function()
local items, setItems = signal({ 1, 2, 3 }) local items, setItems = signal({ 1, 2, 3 })
local a, setA = signal(0) local a, setA = signal(0)
local runs = {} local runs = {}
local disposes = {}
local dispose = observe(items, function(item) local dispose = observe(items, function(item)
effect(function() effect(function()
table.insert(runs, item) table.insert(runs, item)
a() a()
end) end)
effectScope(function()
return function()
table.insert(disposes, item)
end
end)
end) end)
table.clear(runs) table.clear(runs)
setItems({ 1, 2 }) setItems({ 1, 2 })
setA(1) setA(1)
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`) assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
table.clear(runs) table.clear(runs)
table.clear(disposes)
dispose() dispose()
setA(2) setA(2)
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`) assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
end) end)
test("should queue addition", function() test("should queue addition and removal", function()
local items, setItems = signal({ 1 }) local items, setItems = signal({ 1 })
local edits = {} local edits = {}
observe(items, function(item) observe(items, function(item)
setItems({ 1, 2 }) if item == 1 then
setItems({ 1, 2, 3, 4 }) setItems({ 1, 2, 3, 4 })
table.insert(edits, `+{item}`) elseif item == 4 then
return function() setItems({ 1 })
table.insert(edits, `-{item}`)
end end
end)
assert(table.concat(edits) == "+1+2+3+4", `bad edits: {table.concat(edits)}`)
end)
test("should queue removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {}
observe(items, function(item)
setItems({ 1, 2, 3 })
setItems({ 1 })
table.insert(edits, `+{item}`) table.insert(edits, `+{item}`)
return function() return function()
table.insert(edits, `-{item}`) table.insert(edits, `-{item}`)
@ -93,4 +90,23 @@ test("should queue removal", function()
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`) assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
end) end)
test("should handle self removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {}
observe(items, function(item, index)
setItems(function(state)
state = table.clone(state)
state[index] = nil
return state
end)
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
end)
return {} return {}

View file

@ -1,5 +1,5 @@
local core = require("../../core") local core = require("../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../test/suite")
local signal = core.signal local signal = core.signal
local effect = core.effect local effect = core.effect
@ -37,4 +37,14 @@ test("should enforce immutability", function()
end), "should not allow mutation of new value") end), "should not allow mutation of new value")
end) end)
test("should not freeze values with metatables", function()
local src = signal({ object = setmetatable({}, {}) })
assert(
pcall(function()
src().object.a = 2
end),
"should allow mutation of values with metatables"
)
end)
return {} return {}

View file

@ -1,26 +1,17 @@
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
suite.describe("core", function() suite.describe("computed", function()
suite.describe("computed", function() require("./computed.spec")
require("./core/computed.spec") end)
end) suite.describe("effect", function()
suite.describe("effect", function() require("./effect.spec")
require("./core/effect.spec") end)
end) suite.describe("effectScope", function()
suite.describe("effectScope", function() require("./effectScope.spec")
require("./core/effectScope.spec") end)
end) suite.describe("gc", function()
suite.describe("gc", function() require("./gc.spec")
require("./core/gc.spec")
end)
suite.describe("signal", function()
require("./core/signal.spec")
end)
suite.describe("topology", function()
require("./core/topology.spec")
end)
end) end)
suite.describe("listen", function() suite.describe("listen", function()
require("./listen.spec") require("./listen.spec")
end) end)
@ -30,14 +21,20 @@ end)
suite.describe("observe", function() suite.describe("observe", function()
require("./observe.spec") require("./observe.spec")
end) end)
suite.describe("peek", function()
require("./peek.spec")
end)
suite.describe("signal", function()
require("./signal.spec")
end)
suite.describe("subscribe", function() suite.describe("subscribe", function()
require("./subscribe.spec") require("./subscribe.spec")
end) end)
suite.describe("topology", function()
require("./topology.spec")
end)
suite.describe("untracked", function() suite.describe("untracked", function()
require("./untracked.spec") require("./untracked.spec")
end) end)
suite.describe("peek", function()
require("./peek.spec")
end)
return {} return {}

View file

@ -1,9 +1,9 @@
local core = require("../../core") local core = require("../core")
local suite = require("../../../../../test/suite") local suite = require("../../../../test/suite")
local atom = core.atom
local computed = core.computed local computed = core.computed
local effect = core.effect local effect = core.effect
local signal = core.signal
local describe = suite.describe local describe = suite.describe
local test = suite.test local test = suite.test
@ -43,7 +43,7 @@ describe("graph updates", function()
-- C -- C
-- | -- |
-- D -- D
local a, setA = signal(2) local a = atom(2)
local b = computed(function() local b = computed(function()
return a() - 1 return a() - 1
@ -64,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
setA(4) a(4)
d() d()
assert(computeCalls == 1, "compute should drop update") assert(computeCalls == 1, "compute should drop update")
end) end)
@ -77,7 +77,7 @@ describe("graph updates", function()
-- B C -- B C
-- \ / -- \ /
-- D -- D
local a, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -95,7 +95,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")
setA("aa") a("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)
@ -109,7 +109,7 @@ describe("graph updates", function()
-- D -- D
-- | -- |
-- E -- E
local a, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -131,7 +131,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")
setA("aa") a("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)
@ -139,7 +139,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, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
a() a()
return "foo" return "foo"
@ -155,7 +155,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")
setA("aa") a("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)
@ -171,7 +171,7 @@ describe("graph updates", function()
-- E -- E
-- / \ -- / \
-- F G -- F G
local a, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
@ -220,7 +220,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0 fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0
setA("b") a("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")
@ -235,7 +235,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0 fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0 gSpyCalls, gSpyTime = 0, 0
setA("c") a("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")
@ -256,7 +256,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, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
@ -271,7 +271,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")
setA("aa") a("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)
@ -285,7 +285,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, setA = signal("a") local a = atom("a")
local spyBCalls = 0 local spyBCalls = 0
local spyB = function() local spyB = function()
spyBCalls += 1 spyBCalls += 1
@ -316,7 +316,7 @@ describe("graph updates", function()
spyCCalls = 0 spyCCalls = 0
unsub() unsub()
setA("aa") a("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")
@ -333,7 +333,7 @@ describe("graph updates", function()
-- B *C <- returns same value every time -- B *C <- returns same value every time
-- \ / -- \ /
-- D -- D
local a, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -351,7 +351,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
setA("aa") a("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)
@ -365,7 +365,7 @@ describe("graph updates", function()
-- B *C *D -- B *C *D
-- \ | / -- \ | /
-- E -- E
local a, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -387,13 +387,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
setA("aa") a("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, setA = signal(0) local a = atom(0)
local b = computed(function() local b = computed(function()
return a() return a()
end) end)
@ -402,10 +402,10 @@ describe("graph updates", function()
end) end)
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
setA(1) a(1)
assert(c() == 1, "c should be 1") assert(c() == 1, "c should be 1")
setA(0) a(0)
assert(c() == 0, "c should be 0 again") assert(c() == 0, "c should be 0 again")
end) end)
@ -417,7 +417,7 @@ describe("graph updates", function()
-- *B *C -- *B *C
-- \ / -- \ /
-- D -- D
local a, setA = signal("a") local a = atom("a")
local b = computed(function() local b = computed(function()
a() a()
return "b" return "b"
@ -436,14 +436,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
setA("aa") a("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, setA = signal(0) local a = atom(0)
local b = computed(function() local b = computed(function()
error("fail") error("fail")
end) end)
@ -457,12 +457,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")
setA(1) a(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, setA = signal(0) local a = atom(0)
local b = computed(function() local b = computed(function()
if a() == 1 then if a() == 1 then
error("fail") error("fail")
@ -475,14 +475,14 @@ describe("error handling", function()
assert(c() == 0, "c should be 0") assert(c() == 0, "c should be 0")
setA(1) a(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")
setA(2) a(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

@ -167,21 +167,17 @@ local function createReactiveSystem(props: {
end end
local function propagate(link: Link): () local function propagate(link: Link): ()
local next = link.nextSub
local stack: Stack<Link?>?
repeat repeat
local sub = link.sub local sub = link.sub
local flags = sub.flags local flags = sub.flags
if not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) then if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING) sub.flags = bit32.bor(flags, PENDING)
elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
flags = NONE flags = NONE
elseif not bit32.btest(flags, RECURSED_CHECK) then elseif bit32.band(flags, RECURSED_CHECK) == 0 then
sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING) sub.flags = bit32.bor(bit32.band(flags, bit32.bnot(RECURSED)), PENDING)
elseif not bit32.btest(flags, bit32.bor(DIRTY, PENDING)) and isValidLink(link, sub) then elseif bit32.band(flags, bit32.bor(DIRTY, PENDING)) == 0 and isValidLink(link, sub) then
sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING)) sub.flags = bit32.bor(flags, bit32.bor(RECURSED, PENDING))
flags = bit32.band(flags, MUTABLE) flags = bit32.band(flags, MUTABLE)
else else
@ -196,41 +192,12 @@ local function createReactiveSystem(props: {
if bit32.btest(flags, MUTABLE) then if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs local subSubs = sub.subs
if subSubs then if subSubs then
link = subSubs propagate(subSubs)
local nextSub = link.nextSub
if nextSub then
stack = { value = next, prev = stack }
next = nextSub
end
continue
end end
end end
link = next :: Link link = link.nextSub :: Link
until not link
if link then
next = link.nextSub
continue
end
local continueTop = false
while stack do
link = stack.value :: Link
stack = stack.prev
if link then
next = link.nextSub
continueTop = true
break
end
end
if continueTop then
continue
end
break
until false
end end
local function shallowPropagate(link: Link): () local function shallowPropagate(link: Link): ()
@ -248,81 +215,38 @@ local function createReactiveSystem(props: {
end end
local function checkDirty(link: Link, sub: ReactiveNode): boolean local function checkDirty(link: Link, sub: ReactiveNode): boolean
local stack: Stack<Link>?
local checkDepth = 0
local dirty = false
repeat repeat
local dep = link.dep local dep = link.dep
local flags = dep.flags local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then if bit32.btest(sub.flags, DIRTY) then
dirty = true return true
elseif bit32.band(flags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then if update(dep) then
local subs = dep.subs :: Link local subs = dep.subs :: Link
if subs.nextSub then if subs.nextSub then
shallowPropagate(subs) shallowPropagate(subs)
end end
dirty = true return true
end end
elseif bit32.band(flags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
if link.nextSub or link.prevSub then if checkDirty(dep.deps :: Link, dep) then
stack = { value = link, prev = stack } if update(dep) then
end local subs = dep.subs :: Link
link = dep.deps :: Link if subs.nextSub then
sub = dep shallowPropagate(subs)
checkDepth += 1
continue
end
if not dirty then
local nextDep = link.nextDep
if nextDep then
link = nextDep
continue
end
end
local continueTop = false
while checkDepth ~= 0 do
checkDepth -= 1
local firstSub = sub.subs :: Link
local hasMultipleSubs = firstSub.nextSub ~= nil
if hasMultipleSubs then
link = (stack :: Stack<Link>).value
stack = (stack :: Stack<Link>).prev
else
link = firstSub
end
if dirty then
if update(sub) then
if hasMultipleSubs then
shallowPropagate(firstSub)
end end
sub = link.sub return true
continue
end end
dirty = false
else else
sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING)) dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
end
sub = link.sub
local nextDep = link.nextDep
if nextDep then
link = nextDep
continueTop = true
break
end end
end end
if continueTop then link = link.nextDep :: Link
continue until not link
end
return dirty return false
until false
end end
return { return {

View file

@ -235,7 +235,7 @@ local function signalOper<T>(
local value: T local value: T
if type(update) == "function" then if type(update) == "function" then
local prevSub = setActiveSub(nil) local prevSub = setActiveSub(nil)
local success, result: T = (if globals.strict then noYield else pcall)(update, this.currentValue) local success, result: T = (if globals.strict then noYield else pcall)(update, this.pendingValue)
activeSub = prevSub activeSub = prevSub
if not success then if not success then
error(result) error(result)
@ -278,9 +278,7 @@ local function signalOper<T>(
break break
end end
local subs = sub.subs local subs = sub.subs
if subs then sub = if subs then subs.sub else nil
sub = subs.sub
end
end end
return this.currentValue return this.currentValue
end end
@ -434,17 +432,21 @@ end
--- For an alternative that still cleans up inner effects, use `peek` instead. --- For an alternative that still cleans up inner effects, use `peek` instead.
--- ---
--- @param callback The callback function. --- @param callback The callback function.
--- @param args Arguments to pass to the callback. --- @param ...args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback --- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
local prevSub = setActiveSub(nil) if activeSub then
local results: { any } = { pcall(callback, ...) } local prevSub = setActiveSub(nil)
activeSub = prevSub local results: { any } = { pcall(callback, ...) }
if not results[1] then activeSub = prevSub
error(results[2]) if not results[1] then
error(results[2])
end
return unpack(results, 2)
else
return callback(...)
end end
return unpack(results, 2)
end end
--- Runs the callback without subscribing to signal updates, but still captures --- Runs the callback without subscribing to signal updates, but still captures
@ -453,7 +455,7 @@ end
--- To avoid capturing inner effects, use `untracked` instead. --- To avoid capturing inner effects, use `untracked` instead.
--- ---
--- @param callback The callback function. --- @param callback The callback function.
--- @param args Arguments to pass to the callback. --- @param ...args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback --- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
@ -478,10 +480,10 @@ end
--- computed signals after the provided callback finishes running. --- computed signals after the provided callback finishes running.
--- ---
--- @param callback A function that performs multiple updates. --- @param callback A function that performs multiple updates.
--- @param args Arguments to pass to the callback. --- @param ...args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback --- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results... local function batch<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
startBatch() startBatch()
local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) }
endBatch() endBatch()
@ -497,7 +499,7 @@ end
--- ---
--- @param getter A function that returns the value to track. --- @param getter A function that returns the value to track.
--- @return A computed signal that tracks the value returned by the getter. --- @return A computed signal that tracks the value returned by the getter.
local function toComputed<T>(getter: () -> T): () -> T local function toSignal<T>(getter: () -> T): () -> T
local name = debug.info(getter, "n") local name = debug.info(getter, "n")
if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then
return getter return getter
@ -519,7 +521,7 @@ end
--- @return A function for disposing the effect. --- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback --- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
local function listen<T>(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> () local function listen<T>(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> ()
local tracked = toComputed(getter) local tracked = toSignal(getter)
local current: T local current: T
return effect(function() return effect(function()
local previous = current local previous = current
@ -540,15 +542,12 @@ end
--- @return A function for disposing the effect. --- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback --- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
local function subscribe<T>(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () local function subscribe<T>(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> ()
local tracked = toComputed(getter) local tracked = toSignal(getter)
local current: T local current = tracked()
local mounted = true
return effect(function() return effect(function()
local previous = current local previous = current
current = tracked() current = tracked()
if mounted then if current ~= previous then
mounted = false
else
peek(callback, current, previous) peek(callback, current, previous)
end end
end) end)
@ -578,12 +577,14 @@ local reactiveSystem = createReactiveSystem({
queuedLength = insertIndex queuedLength = insertIndex
-- while (firstInsertedIndex < --insertIndex)
insertIndex -= 1 insertIndex -= 1
while firstInsertedIndex < insertIndex do while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex] local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex] queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left queued[insertIndex] = left
firstInsertedIndex += 1 firstInsertedIndex += 1
-- Decrement before next iteration to simulate --insertIndex
insertIndex -= 1 insertIndex -= 1
end end
end, end,
@ -620,7 +621,7 @@ return {
onCleanup = onCleanup, onCleanup = onCleanup,
untracked = untracked, untracked = untracked,
peek = peek, peek = peek,
batched = batched, batch = batch,
subscribe = subscribe, subscribe = subscribe,
listen = listen, listen = listen,
} }

View file

@ -149,9 +149,9 @@ export function peek<Args extends any[], Result>(callback: (...args: Args) => Re
* @param callback A function that performs multiple updates. * @param callback A function that performs multiple updates.
* @param args Arguments to pass to the callback. * @param args Arguments to pass to the callback.
* @returns The return value of the callback. * @returns The return value of the callback.
* @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback * @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
*/ */
export function batched<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result; export function batch<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/** /**
* Creates an effect that runs the callback one immediately, and then again * Creates an effect that runs the callback one immediately, and then again

View file

@ -11,11 +11,22 @@ type Update<T> = ((previous: T) -> T) | T
--- its current value. --- its current value.
export type Atom<T = any> = (...Update<T>) -> T export type Atom<T = any> = (...Update<T>) -> T
--- A getter-only reactive signal. Calling the signal with no arguments returns
--- its current value. It cannot be set directly.
export type Getter<T = any> = () -> T
--- A setter-only reactive signal. Calling the signal with an argument sets its
--- value. It cannot be read directly.
export type Setter<T = any> = (...Update<T>) -> ()
export type ReactiveNode = core.ReactiveNode
export type Link = core.Link
return { return {
core = core, core = core,
globals = globals, globals = globals,
atom = core.atom, atom = core.atom,
batched = core.batched, batch = core.batch,
computed = core.computed, computed = core.computed,
effect = core.effect, effect = core.effect,
effectScope = core.effectScope, effectScope = core.effectScope,

View file

@ -1,70 +1,66 @@
local core = require("./core") local core = require("./core")
local globals = require("./globals")
local batched = core.batched type ReactiveNode = core.ReactiveNode
local batch = core.batch
local effect = core.effect local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope
local onCleanup = core.onCleanup
--- Calls the callback function for each unique key in the table returned by the --- Calls the callback function for each unique key in the table returned by the
--- getter. The callback is called once for each key, and again when a new key --- getter. The callback is called once for each key, and again when a new key
--- is added. --- is added.
--- ---
--- The callback may return a cleanup function, which will be called when the --- The callback may return a cleanup function, which will be called when the
--- key is removed or when the entire callback is disposed. --- key is removed or the `observe()` effect is disposed. Effects created
--- --- during the callback will also be bound to the lifetime of the key.
--- Effects created within the callback are cleaned up when the item is removed
--- from the table or when the callback is disposed.
--- ---
--- @param getter A function that returns the table to observe. --- @param getter A function that returns the table to observe.
--- @param callback A function that is called for each key in the table. --- @param callback A function that is called for each key in the table.
--- @return A function for disposing the observer. --- @return A function for disposing the observer.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback --- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
local function observe<K, V>(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> () local function observe<K, V>(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> ()
local scopes: { [K]: () -> () } = {} local innerScopes: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {}
local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V }) local function updateScopes(data: { [K]: V })
for key in previousItems do for key, dispose in innerScopes do
local dispose = scopes[key] if data[key] == nil then
if currentItems[key] == nil and dispose then innerScopes[key] = nil
dispose() dispose()
scopes[key] = nil
end end
end end
for key, value in currentItems do for key, value in data do
if previousItems[key] == nil and scopes[key] == nil then if innerScopes[key] == nil then
scopes[key] = effectScope(function() -- Detach the inner scope from the parent effect to prevent
-- disposing when the effect re-runs.
innerScopes[key] = effectScope(function()
return callback(value, key) return callback(value, key)
end, true) end, true)
end end
end end
end end
local function disposeScopes() return effectScope(function()
for _, dispose in scopes do effect(function()
dispose() -- Batch so that side effects run after all scopes are created. This
end -- ensures that a scope creating or removing items doesn't interfere
table.clear(scopes) -- with the current update cycle.
end batch(updateScopes, getter())
local disposeEffect = effect(function() -- Effects can't trigger themselves more than once, so if a scope
local previousItems = currentItems -- creates or removes items, we need to check for updates again. The
currentItems = getter() -- way we do this abuses a weird property of signal accesses that
if currentItems ~= previousItems then -- causes the effect to re-run.
batched(updateScopes, currentItems, previousItems) getter()
end)
return function()
for _, dispose in innerScopes do
dispose()
end
table.clear(innerScopes)
end end
end) end)
if globals.trackInnerEffects then
onCleanup(disposeScopes, true)
end
return function()
disposeEffect()
disposeScopes()
end
end end
return observe return observe

View file

@ -14,7 +14,7 @@ local function noYield<Args..., Results...>(callback: (Args...) -> Results..., .
end end
local function deepFreeze(object: any) local function deepFreeze(object: any)
if type(object) == "table" and not table.isfrozen(object) then if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then
table.freeze(object) table.freeze(object)
for _, value in object do for _, value in object do
deepFreeze(value) deepFreeze(value)