From 7bb6edb4036a42f3b9f073b07aa7b6dedf8ad39e Mon Sep 17 00:00:00 2001
From: littensy <56808540+littensy@users.noreply.github.com>
Date: Tue, 4 Nov 2025 21:39:28 -0800
Subject: [PATCH] refactor: rename batch, misc fixes & perf updates
---
README.md | 21 +--
packages/charm-sync/src/client.luau | 4 +-
.../__tests__/{core => }/computed.spec.luau | 4 +-
.../src/__tests__/{core => }/effect.spec.luau | 4 +-
.../{core => }/effectScope.spec.luau | 4 +-
.../src/__tests__/{core => }/gc.spec.luau | 4 +-
.../charm/src/__tests__/observe.spec.luau | 52 +++++---
.../src/__tests__/{core => }/signal.spec.luau | 14 +-
packages/charm/src/__tests__/spec.luau | 43 +++----
.../__tests__/{core => }/topology.spec.luau | 64 +++++-----
packages/charm/src/alien.luau | 120 ++++--------------
packages/charm/src/core.luau | 49 +++----
packages/charm/src/index.d.ts | 4 +-
packages/charm/src/init.luau | 13 +-
packages/charm/src/observe.luau | 70 +++++-----
packages/charm/src/utils.luau | 2 +-
16 files changed, 214 insertions(+), 258 deletions(-)
rename packages/charm/src/__tests__/{core => }/computed.spec.luau (96%)
rename packages/charm/src/__tests__/{core => }/effect.spec.luau (98%)
rename packages/charm/src/__tests__/{core => }/effectScope.spec.luau (94%)
rename packages/charm/src/__tests__/{core => }/gc.spec.luau (95%)
rename packages/charm/src/__tests__/{core => }/signal.spec.luau (74%)
rename packages/charm/src/__tests__/{core => }/topology.spec.luau (94%)
diff --git a/README.md b/README.md
index ece7086..01a90a8 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@
- [`effect(callback)`](#effectcallback)
- [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- - [`batched(callback)`](#batchedcallback)
+ - [`batch(callback)`](#batchcallback)
- [`effectScope(callback)`](#effectscopecallback)
- [`listen(getter, callback)`](#listengetter-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.
@@ -348,7 +348,7 @@ end)
-- Combines both writes into a single update.
-- Once the callback completes, outputs "Full name: Foo Bar"
-batched(function()
+batch(function()
setName("Foo")
setSurname("Bar")
end)
@@ -542,13 +542,15 @@ max(-1) --> 1
### `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 |
-| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
-| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. |
-| 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. |
+The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio.
+
+| Flag | Default | Description |
+| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 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:**
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
- 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)`)
diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau
index e067be5..8392792 100644
--- a/packages/charm-sync/src/client.luau
+++ b/packages/charm-sync/src/client.luau
@@ -65,9 +65,9 @@ end
local function hydrate(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
- Charm.batched(applyInitPayload, payload.data)
+ Charm.batch(applyInitPayload, payload.data)
elseif payload.type == "patch" then
- Charm.batched(applyPatchPayload, payload.data)
+ Charm.batch(applyPatchPayload, payload.data)
end
end
end
diff --git a/packages/charm/src/__tests__/core/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau
similarity index 96%
rename from packages/charm/src/__tests__/core/computed.spec.luau
rename to packages/charm/src/__tests__/computed.spec.luau
index 939d72a..522f403 100644
--- a/packages/charm/src/__tests__/core/computed.spec.luau
+++ b/packages/charm/src/__tests__/computed.spec.luau
@@ -1,5 +1,5 @@
-local core = require("../../core")
-local suite = require("../../../../../test/suite")
+local core = require("../core")
+local suite = require("../../../../test/suite")
local computed = core.computed
local effect = core.effect
diff --git a/packages/charm/src/__tests__/core/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau
similarity index 98%
rename from packages/charm/src/__tests__/core/effect.spec.luau
rename to packages/charm/src/__tests__/effect.spec.luau
index b9db5ac..b1ba2bf 100644
--- a/packages/charm/src/__tests__/core/effect.spec.luau
+++ b/packages/charm/src/__tests__/effect.spec.luau
@@ -1,5 +1,5 @@
-local core = require("../../core")
-local suite = require("../../../../../test/suite")
+local core = require("../core")
+local suite = require("../../../../test/suite")
local computed = core.computed
local effect = core.effect
diff --git a/packages/charm/src/__tests__/core/effectScope.spec.luau b/packages/charm/src/__tests__/effectScope.spec.luau
similarity index 94%
rename from packages/charm/src/__tests__/core/effectScope.spec.luau
rename to packages/charm/src/__tests__/effectScope.spec.luau
index b2dcdc7..77798ff 100644
--- a/packages/charm/src/__tests__/core/effectScope.spec.luau
+++ b/packages/charm/src/__tests__/effectScope.spec.luau
@@ -1,5 +1,5 @@
-local core = require("../../core")
-local suite = require("../../../../../test/suite")
+local core = require("../core")
+local suite = require("../../../../test/suite")
local effect = core.effect
local effectScope = core.effectScope
diff --git a/packages/charm/src/__tests__/core/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau
similarity index 95%
rename from packages/charm/src/__tests__/core/gc.spec.luau
rename to packages/charm/src/__tests__/gc.spec.luau
index d5b9bfc..cf4b213 100644
--- a/packages/charm/src/__tests__/core/gc.spec.luau
+++ b/packages/charm/src/__tests__/gc.spec.luau
@@ -1,5 +1,5 @@
-local core = require("../../core")
-local suite = require("../../../../../test/suite")
+local core = require("../core")
+local suite = require("../../../../test/suite")
local atom = core.atom
local computed = core.computed
diff --git a/packages/charm/src/__tests__/observe.spec.luau b/packages/charm/src/__tests__/observe.spec.luau
index 1119b4e..01add3d 100644
--- a/packages/charm/src/__tests__/observe.spec.luau
+++ b/packages/charm/src/__tests__/observe.spec.luau
@@ -4,6 +4,7 @@ local suite = require("../../../../test/suite")
local signal = Charm.signal
local computed = Charm.computed
local effect = Charm.effect
+local effectScope = Charm.effectScope
local observe = Charm.observe
local test = suite.test
@@ -42,48 +43,44 @@ test("should dispose scopes", function()
local items, setItems = signal({ 1, 2, 3 })
local a, setA = signal(0)
local runs = {}
+ local disposes = {}
local dispose = observe(items, function(item)
effect(function()
table.insert(runs, item)
a()
end)
+ effectScope(function()
+ return function()
+ table.insert(disposes, item)
+ end
+ end)
end)
table.clear(runs)
setItems({ 1, 2 })
setA(1)
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(disposes)
dispose()
setA(2)
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
+ assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
end)
-test("should queue addition", function()
+test("should queue addition and removal", function()
local items, setItems = signal({ 1 })
local edits = {}
observe(items, function(item)
- setItems({ 1, 2 })
- setItems({ 1, 2, 3, 4 })
- table.insert(edits, `+{item}`)
- return function()
- table.insert(edits, `-{item}`)
+ if item == 1 then
+ setItems({ 1, 2, 3, 4 })
+ elseif item == 4 then
+ setItems({ 1 })
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}`)
return function()
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)}`)
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 {}
diff --git a/packages/charm/src/__tests__/core/signal.spec.luau b/packages/charm/src/__tests__/signal.spec.luau
similarity index 74%
rename from packages/charm/src/__tests__/core/signal.spec.luau
rename to packages/charm/src/__tests__/signal.spec.luau
index e63b4f6..6197693 100644
--- a/packages/charm/src/__tests__/core/signal.spec.luau
+++ b/packages/charm/src/__tests__/signal.spec.luau
@@ -1,5 +1,5 @@
-local core = require("../../core")
-local suite = require("../../../../../test/suite")
+local core = require("../core")
+local suite = require("../../../../test/suite")
local signal = core.signal
local effect = core.effect
@@ -37,4 +37,14 @@ test("should enforce immutability", function()
end), "should not allow mutation of new value")
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 {}
diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau
index 4560796..d603583 100644
--- a/packages/charm/src/__tests__/spec.luau
+++ b/packages/charm/src/__tests__/spec.luau
@@ -1,26 +1,17 @@
local suite = require("../../../../test/suite")
-suite.describe("core", function()
- suite.describe("computed", function()
- require("./core/computed.spec")
- end)
- suite.describe("effect", function()
- require("./core/effect.spec")
- end)
- suite.describe("effectScope", function()
- require("./core/effectScope.spec")
- end)
- suite.describe("gc", function()
- require("./core/gc.spec")
- end)
- suite.describe("signal", function()
- require("./core/signal.spec")
- end)
- suite.describe("topology", function()
- require("./core/topology.spec")
- end)
+suite.describe("computed", function()
+ require("./computed.spec")
+end)
+suite.describe("effect", function()
+ require("./effect.spec")
+end)
+suite.describe("effectScope", function()
+ require("./effectScope.spec")
+end)
+suite.describe("gc", function()
+ require("./gc.spec")
end)
-
suite.describe("listen", function()
require("./listen.spec")
end)
@@ -30,14 +21,20 @@ end)
suite.describe("observe", function()
require("./observe.spec")
end)
+suite.describe("peek", function()
+ require("./peek.spec")
+end)
+suite.describe("signal", function()
+ require("./signal.spec")
+end)
suite.describe("subscribe", function()
require("./subscribe.spec")
end)
+suite.describe("topology", function()
+ require("./topology.spec")
+end)
suite.describe("untracked", function()
require("./untracked.spec")
end)
-suite.describe("peek", function()
- require("./peek.spec")
-end)
return {}
diff --git a/packages/charm/src/__tests__/core/topology.spec.luau b/packages/charm/src/__tests__/topology.spec.luau
similarity index 94%
rename from packages/charm/src/__tests__/core/topology.spec.luau
rename to packages/charm/src/__tests__/topology.spec.luau
index f40837d..0cffebd 100644
--- a/packages/charm/src/__tests__/core/topology.spec.luau
+++ b/packages/charm/src/__tests__/topology.spec.luau
@@ -1,9 +1,9 @@
-local core = require("../../core")
-local suite = require("../../../../../test/suite")
+local core = require("../core")
+local suite = require("../../../../test/suite")
+local atom = core.atom
local computed = core.computed
local effect = core.effect
-local signal = core.signal
local describe = suite.describe
local test = suite.test
@@ -43,7 +43,7 @@ describe("graph updates", function()
-- C
-- |
-- D
- local a, setA = signal(2)
+ local a = atom(2)
local b = computed(function()
return a() - 1
@@ -64,7 +64,7 @@ describe("graph updates", function()
assert(computeCalls == 1, "compute should be called once")
computeCalls = 0
- setA(4)
+ a(4)
d()
assert(computeCalls == 1, "compute should drop update")
end)
@@ -77,7 +77,7 @@ describe("graph updates", function()
-- B C
-- \ /
-- D
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
return a()
end)
@@ -95,7 +95,7 @@ describe("graph updates", function()
assert(d() == "a a", "d should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
- setA("aa")
+ a("aa")
assert(d() == "aa aa", "d should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
@@ -109,7 +109,7 @@ describe("graph updates", function()
-- D
-- |
-- E
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
return a()
end)
@@ -131,7 +131,7 @@ describe("graph updates", function()
assert(e() == "a a", "e should be 'a a'")
assert(spyCalls == 1, "compute should be called once")
- setA("aa")
+ a("aa")
assert(e() == "aa aa", "e should be 'aa aa'")
assert(spyCalls == 2, "compute should be called only twice")
end)
@@ -139,7 +139,7 @@ describe("graph updates", function()
test("should bail out if result is the same", function()
-- Bail out if value of "B" never changes
-- A->B->C
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
a()
return "foo"
@@ -155,7 +155,7 @@ describe("graph updates", function()
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should be called once")
- setA("aa")
+ a("aa")
assert(c() == "foo", "c should be 'foo'")
assert(spyCalls == 1, "compute should bail out")
end)
@@ -171,7 +171,7 @@ describe("graph updates", function()
-- E
-- / \
-- F G
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
return a()
@@ -220,7 +220,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
- setA("b")
+ a("b")
assert(e() == "b b", "e should be 'b b'")
assert(eSpyCalls == 1, "e should run once after update")
@@ -235,7 +235,7 @@ describe("graph updates", function()
fSpyCalls, fSpyTime = 0, 0
gSpyCalls, gSpyTime = 0, 0
- setA("c")
+ a("c")
assert(e() == "c c", "e should be 'c c'")
assert(eSpyCalls == 1, "e should run once after update")
@@ -256,7 +256,7 @@ describe("graph updates", function()
-- *A
-- / \
-- *B C <- we don't listen to C
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
return a()
@@ -271,7 +271,7 @@ describe("graph updates", function()
assert(b() == "a", "b should be 'a'")
assert(spyCalls == 0, "compute should not run")
- setA("aa")
+ a("aa")
assert(b() == "aa", "b should be 'aa'")
assert(spyCalls == 0, "compute should not run")
end)
@@ -285,7 +285,7 @@ describe("graph updates", function()
-- *B D <- we don't listen to C
-- |
-- *C
- local a, setA = signal("a")
+ local a = atom("a")
local spyBCalls = 0
local spyB = function()
spyBCalls += 1
@@ -316,7 +316,7 @@ describe("graph updates", function()
spyCCalls = 0
unsub()
- setA("aa")
+ a("aa")
assert(spyBCalls == 0, "b 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
-- \ /
-- D
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
return a()
end)
@@ -351,7 +351,7 @@ describe("graph updates", function()
assert(d() == "a c", "d should be 'a c'")
spyResult = nil
- setA("aa")
+ a("aa")
d()
assert(spyResult == "aa c", "d should have returned 'aa c'")
end)
@@ -365,7 +365,7 @@ describe("graph updates", function()
-- B *C *D
-- \ | /
-- E
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
return a()
end)
@@ -387,13 +387,13 @@ describe("graph updates", function()
assert(e() == "a c d", "e should be 'a c d'")
spyResult = nil
- setA("aa")
+ a("aa")
e()
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
end)
test("should support lazy branches", function()
- local a, setA = signal(0)
+ local a = atom(0)
local b = computed(function()
return a()
end)
@@ -402,10 +402,10 @@ describe("graph updates", function()
end)
assert(c() == 0, "c should be 0")
- setA(1)
+ a(1)
assert(c() == 1, "c should be 1")
- setA(0)
+ a(0)
assert(c() == 0, "c should be 0 again")
end)
@@ -417,7 +417,7 @@ describe("graph updates", function()
-- *B *C
-- \ /
-- D
- local a, setA = signal("a")
+ local a = atom("a")
local b = computed(function()
a()
return "b"
@@ -436,14 +436,14 @@ describe("graph updates", function()
assert(d() == "b c", "d should be 'b c'")
spyCalls = 0
- setA("aa")
+ a("aa")
assert(spyCalls == 0, "d should not run")
end)
end)
describe("error handling", function()
test("should keep graph consistent on errors during activation", function()
- local a, setA = signal(0)
+ local a = atom(0)
local b = computed(function()
error("fail")
end)
@@ -457,12 +457,12 @@ describe("error handling", function()
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
- setA(1)
+ a(1)
assert(c() == 1, "c should recover and return 1")
end)
test("should keep graph consistent on errors in computeds", function()
- local a, setA = signal(0)
+ local a = atom(0)
local b = computed(function()
if a() == 1 then
error("fail")
@@ -475,14 +475,14 @@ describe("error handling", function()
assert(c() == 0, "c should be 0")
- setA(1)
+ a(1)
local success, result = pcall(function()
b()
end)
assert(not success, "should throw")
assert(string.match(result, "fail"), "should throw fail")
- setA(2)
+ a(2)
assert(c() == 2, "c should recover and return 2")
end)
end)
diff --git a/packages/charm/src/alien.luau b/packages/charm/src/alien.luau
index 5812be0..498b8b9 100644
--- a/packages/charm/src/alien.luau
+++ b/packages/charm/src/alien.luau
@@ -167,21 +167,17 @@ local function createReactiveSystem(props: {
end
local function propagate(link: Link): ()
- local next = link.nextSub
- local stack: Stack?
-
repeat
local sub = link.sub
-
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)
- elseif not bit32.btest(flags, bit32.bor(RECURSED_CHECK, RECURSED)) then
+ elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
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)
- 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))
flags = bit32.band(flags, MUTABLE)
else
@@ -196,41 +192,12 @@ local function createReactiveSystem(props: {
if bit32.btest(flags, MUTABLE) then
local subSubs = sub.subs
if subSubs then
- link = subSubs
- local nextSub = link.nextSub
- if nextSub then
- stack = { value = next, prev = stack }
- next = nextSub
- end
- continue
+ propagate(subSubs)
end
end
- link = next :: 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
+ link = link.nextSub :: Link
+ until not link
end
local function shallowPropagate(link: Link): ()
@@ -248,81 +215,38 @@ local function createReactiveSystem(props: {
end
local function checkDirty(link: Link, sub: ReactiveNode): boolean
- local stack: Stack?
- local checkDepth = 0
- local dirty = false
-
repeat
local dep = link.dep
- local flags = dep.flags
+ local depFlags = dep.flags
if bit32.btest(sub.flags, DIRTY) then
- dirty = true
- elseif bit32.band(flags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
+ return true
+ elseif bit32.band(depFlags, bit32.bor(MUTABLE, DIRTY)) == bit32.bor(MUTABLE, DIRTY) then
if update(dep) then
local subs = dep.subs :: Link
if subs.nextSub then
shallowPropagate(subs)
end
- dirty = true
+ return true
end
- elseif bit32.band(flags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
- if link.nextSub or link.prevSub then
- stack = { value = link, prev = stack }
- end
- link = dep.deps :: Link
- sub = dep
- 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).value
- stack = (stack :: Stack).prev
- else
- link = firstSub
- end
- if dirty then
- if update(sub) then
- if hasMultipleSubs then
- shallowPropagate(firstSub)
+ elseif bit32.band(depFlags, bit32.bor(MUTABLE, PENDING)) == bit32.bor(MUTABLE, PENDING) then
+ if checkDirty(dep.deps :: Link, dep) then
+ if update(dep) then
+ local subs = dep.subs :: Link
+ if subs.nextSub then
+ shallowPropagate(subs)
end
- sub = link.sub
- continue
+ return true
end
- dirty = false
else
- sub.flags = bit32.band(sub.flags, bit32.bnot(PENDING))
- end
- sub = link.sub
- local nextDep = link.nextDep
- if nextDep then
- link = nextDep
- continueTop = true
- break
+ dep.flags = bit32.band(depFlags, bit32.bnot(PENDING))
end
end
- if continueTop then
- continue
- end
+ link = link.nextDep :: Link
+ until not link
- return dirty
- until false
+ return false
end
return {
diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau
index 3e6ca7a..48d545d 100644
--- a/packages/charm/src/core.luau
+++ b/packages/charm/src/core.luau
@@ -235,7 +235,7 @@ local function signalOper(
local value: T
if type(update) == "function" then
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
if not success then
error(result)
@@ -278,9 +278,7 @@ local function signalOper(
break
end
local subs = sub.subs
- if subs then
- sub = subs.sub
- end
+ sub = if subs then subs.sub else nil
end
return this.currentValue
end
@@ -434,17 +432,21 @@ end
--- For an alternative that still cleans up inner effects, use `peek` instead.
---
--- @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.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
local function untracked(callback: (Args...) -> Results..., ...: Args...): Results...
- local prevSub = setActiveSub(nil)
- local results: { any } = { pcall(callback, ...) }
- activeSub = prevSub
- if not results[1] then
- error(results[2])
+ if activeSub then
+ local prevSub = setActiveSub(nil)
+ local results: { any } = { pcall(callback, ...) }
+ activeSub = prevSub
+ if not results[1] then
+ error(results[2])
+ end
+ return unpack(results, 2)
+ else
+ return callback(...)
end
- return unpack(results, 2)
end
--- Runs the callback without subscribing to signal updates, but still captures
@@ -453,7 +455,7 @@ end
--- To avoid capturing inner effects, use `untracked` instead.
---
--- @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.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
local function peek(callback: (Args...) -> Results..., ...: Args...): Results...
@@ -478,10 +480,10 @@ end
--- computed signals after the provided callback finishes running.
---
--- @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.
---- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback
-local function batched(callback: (Args...) -> Results..., ...: Args...): Results...
+--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
+local function batch(callback: (Args...) -> Results..., ...: Args...): Results...
startBatch()
local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) }
endBatch()
@@ -497,7 +499,7 @@ end
---
--- @param getter A function that returns the value to track.
--- @return A computed signal that tracks the value returned by the getter.
-local function toComputed(getter: () -> T): () -> T
+local function toSignal(getter: () -> T): () -> T
local name = debug.info(getter, "n")
if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then
return getter
@@ -519,7 +521,7 @@ end
--- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
local function listen(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> ()
- local tracked = toComputed(getter)
+ local tracked = toSignal(getter)
local current: T
return effect(function()
local previous = current
@@ -540,15 +542,12 @@ end
--- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
local function subscribe(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> ()
- local tracked = toComputed(getter)
- local current: T
- local mounted = true
+ local tracked = toSignal(getter)
+ local current = tracked()
return effect(function()
local previous = current
current = tracked()
- if mounted then
- mounted = false
- else
+ if current ~= previous then
peek(callback, current, previous)
end
end)
@@ -578,12 +577,14 @@ local reactiveSystem = createReactiveSystem({
queuedLength = insertIndex
+ -- while (firstInsertedIndex < --insertIndex)
insertIndex -= 1
while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex]
queued[insertIndex] = left
firstInsertedIndex += 1
+ -- Decrement before next iteration to simulate --insertIndex
insertIndex -= 1
end
end,
@@ -620,7 +621,7 @@ return {
onCleanup = onCleanup,
untracked = untracked,
peek = peek,
- batched = batched,
+ batch = batch,
subscribe = subscribe,
listen = listen,
}
diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts
index 926a5ea..de9c2c4 100644
--- a/packages/charm/src/index.d.ts
+++ b/packages/charm/src/index.d.ts
@@ -149,9 +149,9 @@ export function peek(callback: (...args: Args) => Re
* @param callback A function that performs multiple updates.
* @param args Arguments to pass to 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(callback: (...args: Args) => Result, ...args: Args): Result;
+export function batch(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Creates an effect that runs the callback one immediately, and then again
diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau
index 62a0daf..88f60ef 100644
--- a/packages/charm/src/init.luau
+++ b/packages/charm/src/init.luau
@@ -11,11 +11,22 @@ type Update = ((previous: T) -> T) | T
--- its current value.
export type Atom = (...Update) -> 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
+
+--- A setter-only reactive signal. Calling the signal with an argument sets its
+--- value. It cannot be read directly.
+export type Setter = (...Update) -> ()
+
+export type ReactiveNode = core.ReactiveNode
+export type Link = core.Link
+
return {
core = core,
globals = globals,
atom = core.atom,
- batched = core.batched,
+ batch = core.batch,
computed = core.computed,
effect = core.effect,
effectScope = core.effectScope,
diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau
index bc8230e..244b08b 100644
--- a/packages/charm/src/observe.luau
+++ b/packages/charm/src/observe.luau
@@ -1,70 +1,66 @@
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 effectScope = core.effectScope
-local onCleanup = core.onCleanup
--- 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
--- is added.
---
--- The callback may return a cleanup function, which will be called when the
---- key is removed or when the entire callback is disposed.
----
---- Effects created within the callback are cleaned up when the item is removed
---- from the table or when the 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.
---
--- @param getter A function that returns the table to observe.
--- @param callback A function that is called for each key in the table.
--- @return A function for disposing the observer.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
local function observe(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> ()
- local scopes: { [K]: () -> () } = {}
- local currentItems: { [K]: V } = {}
+ local innerScopes: { [K]: () -> () } = {}
- local function updateScopes(currentItems: { [K]: V }, previousItems: { [K]: V })
- for key in previousItems do
- local dispose = scopes[key]
- if currentItems[key] == nil and dispose then
+ local function updateScopes(data: { [K]: V })
+ for key, dispose in innerScopes do
+ if data[key] == nil then
+ innerScopes[key] = nil
dispose()
- scopes[key] = nil
end
end
- for key, value in currentItems do
- if previousItems[key] == nil and scopes[key] == nil then
- scopes[key] = effectScope(function()
+ for key, value in data do
+ if innerScopes[key] == nil then
+ -- Detach the inner scope from the parent effect to prevent
+ -- disposing when the effect re-runs.
+ innerScopes[key] = effectScope(function()
return callback(value, key)
end, true)
end
end
end
- local function disposeScopes()
- for _, dispose in scopes do
- dispose()
- end
- table.clear(scopes)
- end
+ return effectScope(function()
+ effect(function()
+ -- Batch so that side effects run after all scopes are created. This
+ -- ensures that a scope creating or removing items doesn't interfere
+ -- with the current update cycle.
+ batch(updateScopes, getter())
- local disposeEffect = effect(function()
- local previousItems = currentItems
- currentItems = getter()
- if currentItems ~= previousItems then
- batched(updateScopes, currentItems, previousItems)
+ -- Effects can't trigger themselves more than once, so if a scope
+ -- creates or removes items, we need to check for updates again. The
+ -- way we do this abuses a weird property of signal accesses that
+ -- causes the effect to re-run.
+ getter()
+ end)
+
+ return function()
+ for _, dispose in innerScopes do
+ dispose()
+ end
+ table.clear(innerScopes)
end
end)
-
- if globals.trackInnerEffects then
- onCleanup(disposeScopes, true)
- end
-
- return function()
- disposeEffect()
- disposeScopes()
- end
end
return observe
diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau
index d5b19cf..367e83c 100644
--- a/packages/charm/src/utils.luau
+++ b/packages/charm/src/utils.luau
@@ -14,7 +14,7 @@ local function noYield(callback: (Args...) -> Results..., .
end
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)
for _, value in object do
deepFreeze(value)