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)