From 898fbdba29edb130f1334a06f664eacf0d71178d Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:28:41 -0800 Subject: [PATCH] feat: add `trigger()`, refactor source --- README.md | 50 +- benches/mapped.luau | 2 +- packages/charm-sync/src/patch.luau | 4 +- packages/charm-sync/src/server.luau | 56 +- .../charm/src/__tests__/computed.spec.luau | 8 +- packages/charm/src/__tests__/effect.spec.luau | 20 +- .../charm/src/__tests__/effectScope.spec.luau | 19 +- packages/charm/src/__tests__/gc.spec.luau | 10 +- packages/charm/src/__tests__/signal.spec.luau | 6 +- packages/charm/src/__tests__/spec.luau | 3 + .../charm/src/__tests__/topology.spec.luau | 8 +- .../charm/src/__tests__/trigger.spec.luau | 85 ++ packages/charm/src/core.luau | 648 ------------- packages/charm/src/globals.luau | 20 - packages/charm/src/index.d.ts | 168 ++-- packages/charm/src/init.luau | 866 +++++++++++++++++- packages/charm/src/mapped.luau | 76 -- packages/charm/src/observe.luau | 75 -- .../charm/src/{alien.luau => system.luau} | 2 +- packages/charm/src/utils.luau | 28 - test/spec.server.luau | 4 +- 21 files changed, 1119 insertions(+), 1039 deletions(-) create mode 100644 packages/charm/src/__tests__/trigger.spec.luau delete mode 100644 packages/charm/src/core.luau delete mode 100644 packages/charm/src/globals.luau delete mode 100644 packages/charm/src/mapped.luau delete mode 100644 packages/charm/src/observe.luau rename packages/charm/src/{alien.luau => system.luau} (99%) delete mode 100644 packages/charm/src/utils.luau diff --git a/README.md b/README.md index b210cbd..5016d83 100644 --- a/README.md +++ b/README.md @@ -558,9 +558,52 @@ end) --> 0, 1, 2, 3 --- -### `globals` +### `trigger(callback)` -You can customize the behavior of Charm through the `globals` table: +The `trigger()` function allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter: + +```luau +local array = signal({} :: { number }) +local length = computed(function() + return #array() +end) + +print(length()) --> 0 + +-- Direct mutation doesn't automatically trigger updates +table.insert(array(), 1) +print(length()) --> Still 0 + +-- Manually trigger updates +trigger(array) +print(length()) --> 1 +``` + +You can also trigger multiple signals at once: + +```luau +local src1 = signal({} :: { number }) +local src2 = signal({} :: { number }) +local total = computed(function() + return #src1() + #src2() +end) + +table.insert(src1(), 1) +table.insert(src2(), 2) + +trigger(function() + src1() + src2() +end) + +print(total()) --> 2 +``` + +--- + +### `flags` + +Charm exposes the following global flags to customize behavior: | Flag | Default | Description | | ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -568,7 +611,7 @@ You can customize the behavior of Charm through the `globals` table: | 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. This 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. +The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower. --- @@ -863,6 +906,7 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that - Added [`listen()`](#listengetter-callback) for running a subscription once immediately - Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once - Added [`onCleanup()`](#oncleanupcallback-failsilently) for running code when the currently-running effect, subscription, or observer cleans up + - Added [`trigger()`](#triggercallback) for triggering updates for table mutations - The [`computed()`](#computedgetter) callback now gets called with the previous computed value - Optimized `computed()` to use lazy evaluation instead of eager updates - Optimized `mapped()` to only map values that changed in the original table diff --git a/benches/mapped.luau b/benches/mapped.luau index d9758fc..6b6d5dc 100644 --- a/benches/mapped.luau +++ b/benches/mapped.luau @@ -3,7 +3,7 @@ local bench = require("./bench") local computed = Charm.computed local mapped = Charm.mapped -local setActiveSub = Charm.core.setActiveSub +local setActiveSub = Charm.setActiveSub local signal = Charm.signal type Entry = { number } diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 39723d5..f56a118 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -3,7 +3,7 @@ local config = require("./config") local types = require("./types") local validate = require("./validate") -local globals = Charm.globals +local flags = Charm.flags export type None = types.None @@ -79,7 +79,7 @@ local function diff(currentState: any, nextState: any): any patches = stringifySparseArray(patches) end - if globals.strict and config.validatePatches then + if flags.strict and config.validatePatches then validate(patches) end end diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 6692d0c..8979e0c 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -5,22 +5,21 @@ local types = require("./types") type SyncPayload = types.SyncPayload -local globals = Charm.globals - type SyncStatus = number + type ClientKeys = { [string]: SyncStatus } type GetterState = { getter: () -> T, currentValue: T, - previousValue: T, + lastSyncedValue: T, dispose: () -> (), } type StateUpdate = { key: string, - currentValue: any, - previousValue: any, + newValue: any, + oldValue: any, } local PENDING_INITIAL_STATE: SyncStatus = 0 @@ -46,22 +45,19 @@ local function startTracking(key: string, getter: () -> T) local trackedState = { getter = getter, currentValue = value, - previousValue = value, + lastSyncedValue = value, } - trackedState.dispose = Charm.effect(function() - local newValue = getter() - + trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue) -- When preserving history, we need to keep track of previous values -- so we can create granular patches for each change. - if config.preserveHistory and trackedState.currentValue ~= newValue then + if config.preserveHistory then table.insert(globalUpdates, { key = key, - currentValue = newValue, - previousValue = trackedState.previousValue, + newValue = newValue, + oldValue = oldValue, }) end - trackedState.currentValue = newValue end) @@ -69,9 +65,9 @@ local function startTracking(key: string, getter: () -> T) end local function stopTracking(key: string) - local tracker = trackedGetters[key] - if tracker then - tracker.dispose() + local trackedState = trackedGetters[key] + if trackedState then + trackedState.dispose() trackedGetters[key] = nil end end @@ -97,7 +93,7 @@ local function diffGlobalUpdateBuffer(): { any } local patches = table.create(patchCount) for index, update in globalUpdates do - patches[index] = patch.diff(update.previousValue, update.currentValue) + patches[index] = patch.diff(update.oldValue, update.newValue) end return patches @@ -132,7 +128,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { elseif status == PENDING_INITIAL_STATE then table.insert(payloads, { type = "init", - data = { [key] = update.currentValue }, + data = { [key] = update.newValue }, }) clientKeys[key] = LISTENING_FOR_CHANGES end @@ -143,10 +139,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { local initData: { [string]: any } = {} for key, status in clientKeys do - local getterState = trackedGetters[key] + local trackedState = trackedGetters[key] - if status == PENDING_INITIAL_STATE and getterState then - initData[key] = getterState.currentValue + if status == PENDING_INITIAL_STATE and trackedState then + initData[key] = trackedState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -162,10 +158,10 @@ local function diffGlobalState(): any local currentGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {} - for key, tracker in trackedGetters do - currentGlobalState[key] = tracker.currentValue - previousGlobalState[key] = tracker.previousValue - tracker.previousValue = tracker.currentValue + for key, trackedState in trackedGetters do + currentGlobalState[key] = trackedState.currentValue + previousGlobalState[key] = trackedState.lastSyncedValue + trackedState.lastSyncedValue = trackedState.currentValue end return patch.diff(previousGlobalState, currentGlobalState) @@ -185,12 +181,12 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo -- Create patches for initialized signals that changed, and add signals -- that are still pending initial state to the init payload. for key, status in clientKeys do - local getterState = trackedGetters[key] + local trackedState = trackedGetters[key] if status == LISTENING_FOR_CHANGES then patchData[key] = globalPatch[key] elseif status == PENDING_INITIAL_STATE then - initData[key] = getterState.currentValue + initData[key] = trackedState.currentValue clientKeys[key] = LISTENING_FOR_CHANGES end end @@ -273,12 +269,6 @@ local function flush() local granular = config.preserveHistory local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState() - -- While payload objects are technically mutable as they are unique to - -- each client, the data inside them is not safe to mutate. - if globals.frozen then - Charm.deepFreeze(globalPatch) - end - for client in clients do local payloads = if granular then selectFromGlobalPatchList(client, globalPatch) diff --git a/packages/charm/src/__tests__/computed.spec.luau b/packages/charm/src/__tests__/computed.spec.luau index 522f403..3dd7284 100644 --- a/packages/charm/src/__tests__/computed.spec.luau +++ b/packages/charm/src/__tests__/computed.spec.luau @@ -1,9 +1,9 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local computed = core.computed -local effect = core.effect -local signal = core.signal +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal local test = suite.test test("should correctly propagate changes through computed core", function() diff --git a/packages/charm/src/__tests__/effect.spec.luau b/packages/charm/src/__tests__/effect.spec.luau index 96b0256..56fe9c1 100644 --- a/packages/charm/src/__tests__/effect.spec.luau +++ b/packages/charm/src/__tests__/effect.spec.luau @@ -1,15 +1,15 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope -local endBatch = core.endBatch -local onCleanup = core.onCleanup -local recursive = core.recursive -local setActiveSub = core.setActiveSub -local signal = core.signal -local startBatch = core.startBatch +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope +local endBatch = Charm.endBatch +local onCleanup = Charm.onCleanup +local recursive = Charm.recursive +local setActiveSub = Charm.setActiveSub +local signal = Charm.signal +local startBatch = Charm.startBatch local test = suite.test test("should clear subscriptions when untracked by all subscribers", function() diff --git a/packages/charm/src/__tests__/effectScope.spec.luau b/packages/charm/src/__tests__/effectScope.spec.luau index 77798ff..c484151 100644 --- a/packages/charm/src/__tests__/effectScope.spec.luau +++ b/packages/charm/src/__tests__/effectScope.spec.luau @@ -1,10 +1,10 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local effect = core.effect -local effectScope = core.effectScope -local signal = core.signal -local onCleanup = core.onCleanup +local effect = Charm.effect +local effectScope = Charm.effectScope +local signal = Charm.signal +local onCleanup = Charm.onCleanup local test = suite.test test("should not trigger after stop", function() @@ -71,4 +71,13 @@ test("should run indirect effect cleanup", function() assert(cleanups == 2, "should not cleanup after update") end) +test("should disallow yielding", function() + local success = pcall(function() + effectScope(function() + (if task then task.wait else coroutine.yield :: never)() + end) + end) + assert(not success, "should error on yield") +end) + return {} diff --git a/packages/charm/src/__tests__/gc.spec.luau b/packages/charm/src/__tests__/gc.spec.luau index cf4b213..4a73233 100644 --- a/packages/charm/src/__tests__/gc.spec.luau +++ b/packages/charm/src/__tests__/gc.spec.luau @@ -1,10 +1,10 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local atom = core.atom -local computed = core.computed -local effect = core.effect -local effectScope = core.effectScope +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect +local effectScope = Charm.effectScope local test = suite.test --- Creates a weak reference to a value. diff --git a/packages/charm/src/__tests__/signal.spec.luau b/packages/charm/src/__tests__/signal.spec.luau index 6197693..a9693f6 100644 --- a/packages/charm/src/__tests__/signal.spec.luau +++ b/packages/charm/src/__tests__/signal.spec.luau @@ -1,8 +1,8 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local signal = core.signal -local effect = core.effect +local signal = Charm.signal +local effect = Charm.effect local test = suite.test test("should accept equality function", function() diff --git a/packages/charm/src/__tests__/spec.luau b/packages/charm/src/__tests__/spec.luau index d603583..b95c070 100644 --- a/packages/charm/src/__tests__/spec.luau +++ b/packages/charm/src/__tests__/spec.luau @@ -33,6 +33,9 @@ end) suite.describe("topology", function() require("./topology.spec") end) +suite.describe("trigger", function() + require("./trigger.spec") +end) suite.describe("untracked", function() require("./untracked.spec") end) diff --git a/packages/charm/src/__tests__/topology.spec.luau b/packages/charm/src/__tests__/topology.spec.luau index 0cffebd..6f1e1e0 100644 --- a/packages/charm/src/__tests__/topology.spec.luau +++ b/packages/charm/src/__tests__/topology.spec.luau @@ -1,9 +1,9 @@ -local core = require("../core") +local Charm = require("../") local suite = require("../../../../test/suite") -local atom = core.atom -local computed = core.computed -local effect = core.effect +local atom = Charm.atom +local computed = Charm.computed +local effect = Charm.effect local describe = suite.describe local test = suite.test diff --git a/packages/charm/src/__tests__/trigger.spec.luau b/packages/charm/src/__tests__/trigger.spec.luau new file mode 100644 index 0000000..4055cbe --- /dev/null +++ b/packages/charm/src/__tests__/trigger.spec.luau @@ -0,0 +1,85 @@ +local Charm = require("../") +local suite = require("../../../../test/suite") + +local computed = Charm.computed +local effect = Charm.effect +local signal = Charm.signal +local subscribe = Charm.subscribe +local trigger = Charm.trigger +local test = suite.test +local beforeEach = suite.beforeEach +local afterEach = suite.afterEach + +beforeEach(function() + Charm.flags.frozen = false +end) + +afterEach(function() + Charm.flags.frozen = true +end) + +test("should not throw when triggering with no dependencies", function() + trigger(function() end) +end) + +test("should trigger updates for dependent computed signals", function() + local arr = signal({} :: { number }) + local length = computed(function() + return #arr() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(arr(), 1) + trigger(arr) + assert(length() == 1, "length should be 1") +end) + +test("should trigger updates for the second source signal", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local length = computed(function() + return #src2() + end) + + assert(length() == 0, "initial length should be 0") + table.insert(src2(), 1) + trigger(function() + src1() + src2() + end) + assert(length() == 1, "length should be 1") +end) + +test("should trigger effect once", function() + local src1 = signal({} :: { number }) + local src2 = signal({} :: { number }) + local triggers = 0 + + effect(function() + triggers += 1 + src1() + src2() + end) + assert(triggers == 1, "effect should run once initially") + + trigger(function() + src1() + src2() + end) + assert(triggers == 2, "effect should run twice") +end) + +test("should trigger updates for subscribe", function() + local src = signal({} :: { number }) + local triggers = 0 + + subscribe(src, function() + triggers += 1 + end) + + table.insert(src(), 1) + trigger(src) + assert(triggers == 1, "subscribe callback should run after trigger") +end) + +return {} diff --git a/packages/charm/src/core.luau b/packages/charm/src/core.luau deleted file mode 100644 index 9cf1467..0000000 --- a/packages/charm/src/core.luau +++ /dev/null @@ -1,648 +0,0 @@ -local alien = require("./alien") -local globals = require("./globals") -local utils = require("./utils") - -export type ReactiveFlags = alien.ReactiveFlags -export type ReactiveNode = alien.ReactiveNode -export type Link = alien.Link - -local ReactiveFlags = alien.ReactiveFlags -local createReactiveSystem = alien.createReactiveSystem -local deepFreeze = utils.deepFreeze -local noYield = utils.noYield - ---[[ - Reactive system adapted from alien-signals at - https://github.com/stackblitz/alien-signals/blob/master/src/index.ts - - MIT License - - Copyright (c) 2024-present Johnson Chu - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. -]] - -local NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local RECURSED_CHECK = 0b0000100 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local PEEKING = 0b1000000 - -type EffectScope = ReactiveNode & { - cleanups: { () -> () }, -} - -type Effect = ReactiveNode & { - fn: () -> ...(() -> ())?, - cleanups: { () -> () }, -} - -type Computed = ReactiveNode & { - value: T?, - getter: (previousValue: T?) -> T, -} - -type Signal = ReactiveNode & { - currentValue: T, - pendingValue: T, -} - -local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () -local unlink: (link: Link, sub: ReactiveNode?) -> Link? -local propagate: (link: Link) -> () -local checkDirty: (link: Link, sub: ReactiveNode) -> boolean -local shallowPropagate: (link: Link) -> () - -local cycle = 0 -local batchDepth = 0 -local notifyIndex = 0 -local queued: { Effect? } = {} -local queuedLength = 0 -local activeSub: ReactiveNode? - -local function getActiveSub(): ReactiveNode? - return activeSub -end - -local function setActiveSub(sub: ReactiveNode?) - local prevSub = activeSub - activeSub = sub - return prevSub -end - -local function purgeDeps(sub: ReactiveNode) - local depsTail = sub.depsTail - local dep = if depsTail then depsTail.nextDep else sub.deps - while dep do - dep = unlink(dep, sub) - end -end - -local function runCleanups(effect: EffectScope) - for _, cleanup in effect.cleanups do - local prevSub = setActiveSub(nil) - local success, result: any = (if globals.strict then noYield else pcall)(cleanup) - activeSub = prevSub - if not success then - error(result) - end - end - table.clear(effect.cleanups) -end - -local function run(e: Effect): () - local flags = e.flags - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then - runCleanups(e) - cycle += 1 - e.depsTail = nil - e.flags = bit32.bor(WATCHING, RECURSED_CHECK) - local prevSub = setActiveSub(e) - local success, result = (if globals.strict then noYield else pcall)(e.fn) - activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(e) - if not success then - error(result) - elseif result then - table.insert(e.cleanups, result) - end - else - e.flags = WATCHING - end -end - -local function flush(): () - while notifyIndex < queuedLength do - local effect = queued[notifyIndex] :: Effect - queued[notifyIndex] = nil - notifyIndex += 1 - run(effect) - end - notifyIndex = 0 - queuedLength = 0 -end - -local function getBatchDepth(): number - return batchDepth -end - -local function startBatch() - batchDepth += 1 -end - -local function endBatch() - batchDepth -= 1 - if batchDepth == 0 then - flush() - end -end - -local function updateComputed(c: Computed): boolean - cycle += 1 - c.depsTail = nil - c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(c) - local oldValue = c.value - local success, result = (if globals.strict then noYield else pcall)(c.getter, oldValue) - activeSub = prevSub - c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(c) - if not success then - error(result) - end - if oldValue ~= result then - if globals.frozen then - deepFreeze(result) - end - c.value = result - return true - else - return false - end -end - -local function updateSignal(s: Signal): boolean - s.flags = MUTABLE - local pendingValue = s.pendingValue - if s.currentValue ~= pendingValue then - s.currentValue = pendingValue - return true - else - return false - end -end - -local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) - sub.flags = bit32.band(flags, bit32.bnot(PENDING)) -end - -local function computedOper(this: Computed): () -> T - local function computedGetter(): T - local flags = this.flags - if - bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) - then - if updateComputed(this) then - local subs = this.subs - if subs then - shallowPropagate(subs) - end - end - elseif flags == NONE then - this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(this) - local success, result = pcall(this.getter) - activeSub = prevSub - this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) - if success then - this.value = result - else - error(result) - end - end - if activeSub then - link(this, activeSub, cycle) - end - return this.value :: T - end - return computedGetter -end - -local function signalOper( - this: Signal, - equals: ((current: T, incoming: T) -> boolean)? -): (() -> T, (((T) -> T) | T) -> T) - local function signalSetter(update: ((T) -> T) | T): T - local value: T - if type(update) == "function" then - local prevSub = setActiveSub(nil) - local success, result = (if globals.strict then noYield else pcall)(update, this.pendingValue) - activeSub = prevSub - if not success then - error(result) - end - value = result - else - value = update - end - if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then - if globals.frozen then - deepFreeze(value) - end - this.pendingValue = value - this.flags = bit32.bor(MUTABLE, DIRTY) - local subs = this.subs - if subs then - propagate(subs) - if batchDepth == 0 then - flush() - end - end - return value - end - return this.currentValue - end - - local function signalGetter(): T - if bit32.btest(this.flags, DIRTY) then - if updateSignal(this) then - local subs = this.subs - if subs then - shallowPropagate(subs) - end - end - end - local sub = activeSub - while sub do - if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then - link(this, sub, cycle) - break - end - local subs = sub.subs - sub = if subs then subs.sub else nil - end - return this.currentValue - end - - return signalGetter, signalSetter -end - -local function effectScopeOper(this: EffectScope): () -> () - return function() - this.depsTail = nil - this.flags = NONE - purgeDeps(this) - local sub = this.subs - if sub then - unlink(sub, this) - end - runCleanups(this) - end -end - -local function effectOper(this: Effect): () -> () - return effectScopeOper(this) -end - ---- Creates a reactive signal that stores a value and notifies subscribers when ---- the value changes. The signal provides both a getter and a setter. ---- ---- @param initialValue The initial value of the signal. ---- @param equals A comparator function to determine if the signal's value has changed. ---- @return A tuple containing the getter and setter functions for the signal. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals -local function signal( - initialValue: T, - equals: ((current: T, incoming: T) -> boolean)? -): (() -> T, (((T) -> T) | T) -> T) - if globals.frozen then - deepFreeze(initialValue) - end - return signalOper({ - currentValue = initialValue, - pendingValue = initialValue, - flags = MUTABLE, - }, equals) -end - ---- Creates a reactive atom that acts as both a signal getter and setter. ---- Calling the atom with an argument sets its value, while calling it with no ---- arguments returns its current value. ---- ---- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. ---- @param equals A comparator function to determine if the atom's value has changed. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals -local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T - local getter, setter = signal(initialValue, equals) - local function atomGetterSetter(...) - return if select("#", ...) == 0 then getter() else setter((...)) - end - return atomGetterSetter -end - ---- Creates a new read-only signal that is computed based on the values of ---- other signals. The computed signal's value is automatically updated when a ---- change is detected in any of its dependencies. ---- ---- @param getter A function that produces the next value. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter -local function computed(getter: (previousValue: T?) -> T): () -> T - return computedOper({ - flags = NONE, - getter = getter, - }) -end - ---- Creates an effect that runs a callback in response to signal state changes. ---- An effect tracks which signals are accessed during its execution, and runs ---- the callback when those signals change. ---- ---- The effect callback may return a cleanup function, which gets called once, ---- either before the effect re-runs or when it is disposed. ---- ---- If the effect is called within another effect, it will be disposed when the ---- parent effect re-runs. ---- ---- @param fn The function to run when dependencies change. ---- @return A function for disposing the effect. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback -local function effect(fn: () -> ...(() -> ())?): () -> () - local e: Effect = { - fn = fn, - flags = bit32.bor(WATCHING, RECURSED_CHECK), - cleanups = {}, - } - local prevSub = setActiveSub(e) - if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then - link(e, prevSub, 0) - end - local success, result = (if globals.strict then noYield else pcall)(fn) - activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) - if not success then - error(result) - elseif result then - table.insert(e.cleanups, result) - end - return effectOper(e) -end - ---- Creates an effect scope that can capture reactive effects and computed ---- signals created within it so that they can be disposed together. ---- ---- @param fn A function that may create reactive effects. ---- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. ---- @return A function for disposing all effects created within the scope. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached -local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local e: EffectScope = { - flags = NONE, - cleanups = {}, - } - local prevSub = setActiveSub(e) - if prevSub and not detached then - link(e, prevSub, 0) - end - local success, result = (if globals.strict then noYield else pcall)(fn) - activeSub = prevSub - if not success then - error(result) - elseif result then - table.insert(e.cleanups, result) - end - return effectScopeOper(e) -end - ---- Binds the cleanup function to the current active effect or effect scope. If ---- there is no active effect, a warning is logged, unless `failSilently` is ---- set to `true`. ---- ---- @param callback The cleanup function to register. ---- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently -local function onCleanup(cleanup: () -> (), failSilently: boolean?) - if activeSub and (activeSub :: Effect).cleanups then - table.insert((activeSub :: Effect).cleanups, cleanup) - elseif not failSilently then - warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) - end -end - ---- Runs the callback without subscribing to signal updates or capturing inner ---- effects in the parent effect or scope. ---- ---- 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. ---- @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... - 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 -end - ---- Runs the callback without subscribing to signal updates, but still captures ---- inner effects so that they dispose with the parent effect. ---- ---- To avoid capturing inner effects, use `untracked` instead. ---- ---- @param callback The callback function. ---- @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... - if - activeSub - and bit32.band(activeSub.flags, PEEKING) == 0 - and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) - then - activeSub.flags = bit32.bor(activeSub.flags, PEEKING) - local results: { any } = { pcall(callback, ...) } - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) - if not results[1] then - error(results[2]) - end - return unpack(results, 2) - else - return callback(...) - end -end - ---- Combines multiple updates into a single "commit" that runs effects and ---- computed signals after the provided callback finishes running. ---- ---- @param callback A function that performs multiple updates. ---- @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#batchcallback -local function batch(callback: (Args...) -> Results..., ...: Args...): Results... - startBatch() - local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) } - endBatch() - if not results[1] then - error(results[2]) - end - return unpack(results, 2) -end - ---- Converts a non-signal into a computed signal. If the callback is already a ---- signal, it is returned as-is. Useful for memoizing a getter function ---- without creating unnecessary nested signals. ---- ---- @param getter A function that returns the value to track. ---- @return A computed signal that tracks the value returned by the getter. -local function toSignal(getter: () -> T): () -> T - local name = debug.info(getter, "n") - if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then - return getter - end - return computed(function() - return getter() - end) -end - ---- Creates an effect that runs the callback one immediately, and then again ---- when the value returned by the getter changes. The callback receives the new ---- value and the previous value as arguments. ---- ---- Effects created within the callback are cleaned up when the listener runs ---- or the listener is disposed. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @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 = toSignal(getter) - local current: T - return effect(function() - local previous = current - current = tracked() - peek(callback, current, previous) - end) -end - ---- Creates an effect that only runs the callback when the value returned by the ---- getter changes. The callback receives the new value and the previous value ---- as arguments. ---- ---- Effects created within the callback are cleaned up when the listener runs ---- or the listener is disposed. ---- ---- @param getter A function that returns the value to watch. ---- @param callback The function to run when the value changes. ---- @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 = toSignal(getter) - local current = tracked() - return effect(function() - local previous = current - current = tracked() - if current ~= previous then - peek(callback, current, previous) - end - end) -end - ---- Disables recursive checks for the current effect or computed signal. Useful ---- for effects that intentionally update signals they depend on. ---- ---- @example ---- ```luau ---- effect(function() ---- recursive() ---- setCount(getCount() + 1) ---- end) ---- ``` -local function recursive() - if activeSub then - activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) - else - warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) - end -end - -local reactiveSystem = createReactiveSystem({ - update = function(node) - if node.depsTail then - return updateComputed(node :: Computed) - else - return updateSignal(node :: Signal) - end - end, - notify = function(effect) - local insertIndex = queuedLength - local firstInsertedIndex = insertIndex - - repeat - effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) - queued[insertIndex] = effect :: Effect - insertIndex += 1 - effect = (effect.subs and effect.subs.sub) :: Effect - if not effect or bit32.band(effect.flags, WATCHING) == 0 then - break - end - until false - - 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, - unwatched = function(node) - if bit32.band(node.flags, MUTABLE) == 0 then - effectScopeOper(node :: EffectScope)() - elseif node.depsTail then - node.depsTail = nil - node.flags = bit32.bor(MUTABLE, DIRTY) - purgeDeps(node) - end - end, -}) - -link = reactiveSystem.link -unlink = reactiveSystem.unlink -propagate = reactiveSystem.propagate -checkDirty = reactiveSystem.checkDirty -shallowPropagate = reactiveSystem.shallowPropagate - -return { - ReactiveFlags = ReactiveFlags, - createReactiveSystem = createReactiveSystem, - getActiveSub = getActiveSub, - setActiveSub = setActiveSub, - getBatchDepth = getBatchDepth, - startBatch = startBatch, - endBatch = endBatch, - atom = atom, - signal = signal, - computed = computed, - effect = effect, - effectScope = effectScope, - onCleanup = onCleanup, - untracked = untracked, - peek = peek, - batch = batch, - subscribe = subscribe, - listen = listen, - recursive = recursive, -} diff --git a/packages/charm/src/globals.luau b/packages/charm/src/globals.luau deleted file mode 100644 index af6a381..0000000 --- a/packages/charm/src/globals.luau +++ /dev/null @@ -1,20 +0,0 @@ -local function inlineTest(): string - return debug.info(1, "n") -end - -local isO2 = inlineTest() ~= "inlineTest" -local isDev = not isO2 or _G.__DEV__ == true - -return { - --- Enforces synchronous, non-yielding behavior in signals and effects. - --- Also enables state validation in Charm Sync to catch sync errors early. - --- Enabled if the optimization level is below 2, which is true in studio. - strict = isDev, - --- Enforces immutability of tables by deep-freezing table values. Enabled - --- if the optimization level is below 2, which is true in studio. - frozen = isDev, - --- Whether parent effects should track inner effects and clean them up - --- when the parent effect re-runs. This is usually desirable, but can be - --- disabled to revert to the old behavior. Defaults to `true`. - trackInnerEffects = true, -} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 82ca1d8..cc27c44 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -1,24 +1,48 @@ type Key = string | number | symbol; type Cleanup = () => void; -/** - * A reactive signal that acts as both a getter and setter. Calling the signal - * with an argument sets its value, while calling it with no arguments returns - * its current value. - */ +export enum ReactiveFlags { + None = 0b0000000, + Mutable = 0b0000001, + Watching = 0b0000010, + RecursedCheck = 0b0000100, + Recursed = 0b0001000, + Dirty = 0b0010000, + Pending = 0b0100000, + Peeking = 0b1000000, +} + +export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; +} + +export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub?: Link; + nextSub?: Link; + prevDep?: Link; + nextDep?: Link; +} + export interface Atom { (newValue: T | ((currentValue: T) => T)): T; (): T; } -export type Getter = () => T; export type Setter = (newValue: T | ((currentValue: T) => T)) => T; + export type Equals = (current: T, incoming: T) => boolean; /** * Global flags that modify the behavior of Charm's reactive system. */ -export const globals: { +export const flags: { /** * Enforces synchronous, non-yielding behavior in signals and effects. * Also enables state validation in Charm Sync to catch sync errors early. @@ -92,7 +116,7 @@ export function computed(getter: (previousValue?: T) => T): () => T; * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback */ -export function effect(callback: () => Cleanup | void): Cleanup; +export function effect(fn: () => Cleanup | void): Cleanup; /** * Creates an effect scope that can capture reactive effects and computed @@ -103,55 +127,64 @@ export function effect(callback: () => Cleanup | void): Cleanup; * @returns A function for disposing all effects created within the scope. * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached */ -export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; +export function effectScope(fn: () => Cleanup | void, detached?: boolean): Cleanup; + +/** + * Allows you to manually trigger updates for downstream dependencies when + * you've directly mutated a signal's value without using the signal setter. + * + * @param fn The dependency or a function that calls multiple dependencies. + * @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback + */ +export function trigger(fn: () => void): void; /** * Binds the cleanup function to the current active effect or effect scope. If * there is no active effect, a warning is logged, unless `failSilently` is * set to `true`. * - * @param callback The cleanup function to register. + * @param fn The cleanup function to register. * @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. * @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently */ -export function onCleanup(callback: Cleanup, failSilently?: boolean): void; +export function onCleanup(fn: Cleanup, failSilently?: boolean): void; /** - * Runs the callback without subscribing to signal updates or capturing inner + * Runs the function without subscribing to signal updates or capturing inner * effects in the parent effect or scope. * * 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. - * @returns The return value of the callback. + * @param fn A function that may read signals or create effects. + * @param args Arguments to pass to the function. + * @returns The return value of the function. * @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback */ -export function untracked(callback: (...args: Args) => Result, ...args: Args): Result; +export function untracked(fn: (...args: Args) => Result, ...args: Args): Result; /** - * Runs the callback without subscribing to signal updates, but still captures + * Runs the function without subscribing to signal updates, but still captures * inner effects so that they dispose with the parent effect. * * To avoid capturing inner effects, use `untracked` instead. * - * @param callback The callback function. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. + * @param fn A function that may read signals. + * @param args Arguments to pass to the function. + * @returns The return value of the function. * @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback */ -export function peek(callback: (...args: Args) => Result, ...args: Args): Result; +export function peek(fn: (...args: Args) => Result, ...args: Args): Result; /** * Combines multiple updates into a single "commit" that runs effects and - * computed signals after the provided callback finishes running. + * computed signals after the provided function finishes running. * - * @param callback A function that performs multiple updates. - * @param args Arguments to pass to the callback. - * @returns The return value of the callback. + * @param fn A function that performs multiple updates. + * @param args Arguments to pass to the function. + * @returns The return value of the function. * @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback */ -export function batch(callback: (...args: Args) => Result, ...args: Args): Result; +export function batch(fn: (...args: Args) => Result, ...args: Args): Result; /** * Creates an effect that runs the callback one immediately, and then again @@ -162,11 +195,11 @@ export function batch(callback: (...args: Args) => R * or the listener is disposed. * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback */ -export function listen(getter: () => T, callback: (value: T, previousValue: T | undefined) => void): Cleanup; +export function listen(getter: () => T, fn: (value: T, previousValue: T | undefined) => void): Cleanup; /** * Creates an effect that only runs the callback when the value returned by the @@ -177,11 +210,11 @@ export function listen(getter: () => T, callback: (value: T, previousValue: T * or the listener is disposed. * * @param getter A function that returns the value to watch. - * @param callback The function to run when the value changes. + * @param fn The function to run when the value changes. * @returns A function for disposing the effect. * @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback */ -export function subscribe(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; +export function subscribe(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup; /** * Creates a new read-only signal that is computed by mapping over the entries @@ -219,73 +252,38 @@ export function mapped( * from the table or when the callback is disposed. * * @param getter A function that returns the table to observe. - * @param callback A function that is called for each key in the table. + * @param fn A function that is called for each key in the table. * @returns A function for disposing the observer. * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback */ export function observe( getter: () => Map | ReadonlyMap | readonly V[], - callback: (value: V, key: K) => Cleanup | void, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; // Overload for objects export function observe( getter: () => { readonly [K in Key]: V }, - callback: (value: V, key: K) => Cleanup | void, + fn: (value: V, key: K) => Cleanup | void, ): Cleanup; -export namespace core { - export { signal, effect, effectScope, atom, computed, onCleanup, untracked, peek, batch, listen }; +/** + * Disables recursive checks for the current effect or computed signal. + * Useful for effects that intentionally update signals they depend on. + * + * @example + * ```ts + * effect(() => { + * recursive(); + * setCount(getCount() + 1); + * }) + * ``` + */ +export function recursive(): void; - export enum ReactiveFlags { - None = 0b0000000, - Mutable = 0b0000001, - Watching = 0b0000010, - RecursedCheck = 0b0000100, - Recursed = 0b0001000, - Dirty = 0b0010000, - Pending = 0b0100000, - Peeking = 0b1000000, - } +export function getActiveSub(): ReactiveNode | undefined; - export interface ReactiveNode { - deps?: Link; - depsTail?: Link; - subs?: Link; - subsTail?: Link; - flags: ReactiveFlags; - } +export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; - export interface Link { - version: number; - dep: ReactiveNode; - sub: ReactiveNode; - prevSub?: Link; - nextSub?: Link; - prevDep?: Link; - nextDep?: Link; - } +export function startBatch(): void; - /** - * Disables recursive checks for the current effect or computed signal. - * Useful for effects that intentionally update signals they depend on. - * - * @example - * ```ts - * effect(() => { - * recursive(); - * setCount(getCount() + 1); - * }) - * ``` - */ - export function recursive(): void; - - export function getActiveSub(): ReactiveNode | undefined; - - export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined; - - export function getBatchDepth(): number; - - export function startBatch(): void; - - export function endBatch(): void; -} +export function endBatch(): void; diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 4aee63e..6ba6de2 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,43 +1,841 @@ -local core = require("@self/core") -local globals = require("@self/globals") -local mapped = require("@self/mapped") -local observe = require("@self/observe") -local utils = require("@self/utils") +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/index.ts -type Update = ((previous: T) -> T) | T + MIT License ---- A reactive signal that acts as both a getter and setter. Calling the signal ---- with an argument sets its value, while calling it with no arguments returns ---- its current value. -export type Atom = (...Update) -> T + Copyright (c) 2024-present Johnson Chu ---- A getter-only reactive signal. Calling the signal with no arguments returns ---- its current value. It cannot be set directly. -export type Getter = () -> T + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: ---- A setter-only reactive signal. Calling the signal with an argument sets its ---- value. It cannot be read directly. -export type Setter = (...Update) -> () + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -export type ReactiveNode = core.ReactiveNode -export type Link = core.Link + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +]] + +local system = require("@self/system") + +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode +export type Link = system.Link + +export type Atom = (...((T) -> T) | T) -> T + +type EffectScopeNode = ReactiveNode & { + cleanups: { () -> () }, +} + +type EffectNode = ReactiveNode & { + fn: () -> ...(() -> ())?, + cleanups: { () -> () }, +} + +type ComputedNode = ReactiveNode & { + value: T?, + getter: (previousValue: T?) -> T, +} + +type SignalNode = ReactiveNode & { + currentValue: T, + pendingValue: T, +} + +local NONE = 0b0000000 +local MUTABLE = 0b0000001 +local WATCHING = 0b0000010 +local RECURSED_CHECK = 0b0000100 +local DIRTY = 0b0010000 +local PENDING = 0b0100000 +local PEEKING = 0b1000000 + +local function inlineTest(): string + return debug.info(1, "n") +end + +local flags = { + --- Enforces synchronous, non-yielding behavior in signals and effects. + --- Also enables state validation in Charm Sync to catch sync errors early. + --- Enabled if the optimization level is below 2, which is true in studio. + strict = inlineTest() == "inlineTest", + --- Enforces immutability of tables by deep-freezing table values. Enabled + --- if the optimization level is below 2, which is true in studio. + frozen = inlineTest() == "inlineTest", + --- Whether parent effects should track inner effects and clean them up + --- when the parent effect re-runs. This is usually desirable, but can be + --- disabled to revert to the old behavior. Defaults to `true`. + trackInnerEffects = true, +} + +local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () +local unlink: (link: Link, sub: ReactiveNode?) -> Link? +local propagate: (link: Link) -> () +local checkDirty: (link: Link, sub: ReactiveNode) -> boolean +local shallowPropagate: (link: Link) -> () + +local cycle = 0 +local batchDepth = 0 +local notifyIndex = 0 +local queued: { EffectNode? } = {} +local queuedLength = 0 +local activeSub: ReactiveNode? + +local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... + if not flags.strict then + return fn + end + local function resultHandler(thread: thread, ok: boolean, ...) + if not ok then + error(debug.traceback(thread, tostring((...)))) + elseif coroutine.status(thread) ~= "dead" then + error(debug.traceback(thread, "Yielding is not allowed in strict mode")) + end + return ... + end + return function(...) + local thread = coroutine.create(fn) + return resultHandler(thread, coroutine.resume(thread, ...)) + end +end + +local function deepFreeze(object: any) + if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then + table.freeze(object) + for _, value in object do + deepFreeze(value) + end + end +end + +local function getActiveSub(): ReactiveNode? + return activeSub +end + +local function setActiveSub(sub: ReactiveNode?) + local prevSub = activeSub + activeSub = sub + return prevSub +end + +local function purgeDeps(sub: ReactiveNode) + local depsTail = sub.depsTail + local dep = if depsTail then depsTail.nextDep else sub.deps + while dep do + dep = unlink(dep, sub) + end +end + +local function runCleanups(effect: EffectScopeNode) + local cleanups = effect.cleanups + for key, cleanup in cleanups do + local prevSub = setActiveSub(nil) + local success, result: any = pcall(cleanup) + activeSub = prevSub + if not success then + warn("An error occurred during effect cleanup:", result) + end + cleanups[key] = nil + end +end + +local function run(e: EffectNode): () + local flags = e.flags + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then + runCleanups(e) + cycle += 1 + e.depsTail = nil + e.flags = bit32.bor(WATCHING, RECURSED_CHECK) + local prevSub = setActiveSub(e) + local success, result = pcall(e.fn) + activeSub = prevSub + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(e) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, result) + end + else + e.flags = WATCHING + end +end + +local function flush(): () + while notifyIndex < queuedLength do + local effect = queued[notifyIndex] :: EffectNode + queued[notifyIndex] = nil + notifyIndex += 1 + run(effect) + end + notifyIndex = 0 + queuedLength = 0 +end + +local function startBatch() + batchDepth += 1 +end + +local function endBatch() + batchDepth -= 1 + if batchDepth == 0 then + flush() + end +end + +local function updateComputed(c: ComputedNode): boolean + cycle += 1 + c.depsTail = nil + c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(c) + local oldValue = c.value + local success, result = pcall(c.getter, oldValue) + activeSub = prevSub + c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(c) + if not success then + error(result) + end + if oldValue ~= result then + if flags.frozen then + deepFreeze(result) + end + c.value = result + return true + else + return false + end +end + +local function updateSignal(s: SignalNode): boolean + s.flags = MUTABLE + local pendingValue = s.pendingValue + if s.currentValue ~= pendingValue then + s.currentValue = pendingValue + return true + else + return false + end +end + +local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + sub.flags = bit32.band(flags, bit32.bnot(PENDING)) +end + +local function computedOper(this: ComputedNode): () -> T + local function computedGetter(): T + local flags = this.flags + if + bit32.btest(flags, DIRTY) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) + then + if updateComputed(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + elseif flags == NONE then + this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + local prevSub = setActiveSub(this) + local success, result = pcall(this.getter) + activeSub = prevSub + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + if success then + this.value = result + else + error(result) + end + end + if activeSub then + link(this, activeSub, cycle) + end + return this.value :: T + end + return computedGetter +end + +local function signalOper( + this: SignalNode, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + local function signalSetter(update: ((T) -> T) | T): T + local value: T + if type(update) == "function" then + local prevSub = setActiveSub(nil) + local success, result = pcall(noYield(update), this.pendingValue) + activeSub = prevSub + if not success then + error(result) + end + value = result + else + value = update + end + if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then + if flags.frozen then + deepFreeze(value) + end + this.pendingValue = value + this.flags = bit32.bor(MUTABLE, DIRTY) + local subs = this.subs + if subs then + propagate(subs) + if batchDepth == 0 then + flush() + end + end + return value + end + return this.currentValue + end + + local function signalGetter(): T + if bit32.btest(this.flags, DIRTY) then + if updateSignal(this) then + local subs = this.subs + if subs then + shallowPropagate(subs) + end + end + end + local sub = activeSub + while sub do + if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then + link(this, sub, cycle) + break + end + local subs = sub.subs + sub = if subs then subs.sub else nil + end + return this.currentValue + end + + return signalGetter, signalSetter +end + +local function effectOper(this: EffectScopeNode): () -> () + return function() + this.depsTail = nil + this.flags = NONE + purgeDeps(this) + local sub = this.subs + if sub then + unlink(sub, this) + end + runCleanups(this) + end +end + +--- Creates a reactive signal that stores a value and notifies subscribers when +--- the value changes. The signal provides both a getter and a setter. +--- +--- @param initialValue The initial value of the signal. +--- @param equals A comparator function to determine if the signal's value has changed. +--- @return A tuple containing the getter and setter functions for the signal. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals +local function signal( + initialValue: T, + equals: ((current: T, incoming: T) -> boolean)? +): (() -> T, (((T) -> T) | T) -> T) + if flags.frozen then + deepFreeze(initialValue) + end + return signalOper({ + currentValue = initialValue, + pendingValue = initialValue, + flags = MUTABLE, + }, equals and noYield(equals)) +end + +--- Creates a reactive atom that acts as both a signal getter and setter. +--- Calling the atom with an argument sets its value, while calling it with no +--- arguments returns its current value. +--- +--- @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. +--- @param equals A comparator function to determine if the atom's value has changed. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals +local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom + local getter, setter = signal(initialValue, equals) + local function atomGetterSetter(...) + return if select("#", ...) == 0 then getter() else setter((...)) + end + return atomGetterSetter +end + +--- Creates a new read-only signal that is computed based on the values of +--- other signals. The computed signal's value is automatically updated when a +--- change is detected in any of its dependencies. +--- +--- @param getter A function that produces the next value. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter +local function computed(getter: (previousValue: T?) -> T): () -> T + return computedOper({ + flags = NONE, + getter = noYield(getter), + }) +end + +--- Creates an effect that runs a callback in response to signal state changes. +--- An effect tracks which signals are accessed during its execution, and runs +--- the callback when those signals change. +--- +--- The effect callback may return a cleanup function, which gets called once, +--- either before the effect re-runs or when it is disposed. +--- +--- If the effect is called within another effect, it will be disposed when the +--- parent effect re-runs. +--- +--- @param fn The function to run when dependencies change. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback +local function effect(fn: () -> ...(() -> ())?): () -> () + local e: EffectNode = { + fn = noYield(fn), + flags = bit32.bor(WATCHING, RECURSED_CHECK), + cleanups = {}, + } + local prevSub = setActiveSub(e) + if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then + link(e, prevSub, 0) + end + local success, result = pcall(e.fn) + activeSub = prevSub + e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) + end + return effectOper(e) +end + +--- Creates an effect scope that can capture reactive effects and computed +--- signals created within it so that they can be disposed together. +--- +--- @param fn A function that may create reactive effects. +--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. +--- @return A function for disposing all effects created within the scope. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached +local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () + local e: EffectScopeNode = { + flags = NONE, + cleanups = {}, + } + local prevSub = setActiveSub(e) + if prevSub and not detached then + link(e, prevSub, 0) + end + local success, result = pcall(noYield(fn)) + activeSub = prevSub + if not success then + error(result) + elseif result then + table.insert(e.cleanups, noYield(result)) + end + return effectOper(e) +end + +--- Allows you to manually trigger updates for downstream dependencies when +--- you've directly mutated a signal's value without using the signal setter. +--- +--- @param fn The dependency or a function that calls multiple dependencies. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback +local function trigger(fn: () -> ()) + local sub: ReactiveNode = { + flags = WATCHING, + } + local prevSub = setActiveSub(sub) + local success, result: any = pcall(noYield(fn)) + activeSub = prevSub + while sub.deps do + local link = sub.deps :: Link + local dep = link.dep + unlink(link, sub) + if dep.subs then + propagate(dep.subs) + shallowPropagate(dep.subs) + end + end + if batchDepth == 0 then + flush() + end + if not success then + error(result) + end +end + +--- Binds the cleanup function to the current active effect or effect scope. If +--- there is no active effect, a warning is logged, unless `failSilently` is +--- set to `true`. +--- +--- @param fn The cleanup function to register. +--- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently +local function onCleanup(fn: () -> (), failSilently: boolean?) + if activeSub and (activeSub :: EffectNode).cleanups then + table.insert((activeSub :: EffectNode).cleanups, noYield(fn)) + elseif not failSilently then + warn(debug.traceback("onCleanup() can only be called inside an effect or a scope.", 2)) + end +end + +--- Runs the function without subscribing to signal updates or capturing inner +--- effects in the parent effect or scope. +--- +--- For an alternative that still cleans up inner effects, use `peek` instead. +--- +--- @param fn A function that may read signals or create effects. +--- @param ...args Arguments to pass to the function. +--- @return The return value of the function. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback +local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... + if activeSub then + local prevSub = setActiveSub(nil) + local results: { any } = { pcall(noYield(fn), ...) } + activeSub = prevSub + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + else + return fn(...) + end +end + +--- Runs the function without subscribing to signal updates, but still captures +--- inner effects so that they dispose with the parent effect. +--- +--- To avoid capturing inner effects, use `untracked` instead. +--- +--- @param fn A function that may read signals. +--- @param ...args Arguments to pass to the function. +--- @return The return value of the function. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback +local function peek(fn: (Args...) -> Results..., ...: Args...): Results... + if + activeSub + and bit32.band(activeSub.flags, PEEKING) == 0 + and bit32.btest(activeSub.flags, bit32.bor(WATCHING, MUTABLE)) + then + activeSub.flags = bit32.bor(activeSub.flags, PEEKING) + local results: { any } = { pcall(noYield(fn), ...) } + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(PEEKING)) + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + else + return fn(...) + end +end + +--- Combines multiple updates into a single "commit" that runs effects and +--- computed signals after the provided callback finishes running. +--- +--- @param fn A function that performs multiple updates. +--- @param ...args Arguments to pass to the function. +--- @return The return value of the function. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback +local function batch(fn: (Args...) -> Results..., ...: Args...): Results... + startBatch() + local results: { any } = { pcall(noYield(fn), ...) } + endBatch() + if not results[1] then + error(results[2]) + end + return unpack(results, 2) +end + +--- Converts a non-signal into a computed signal. If the function is already a +--- signal, it is returned as-is. Useful for memoizing a getter function +--- without creating unnecessary nested signals. +--- +--- @param fn A function that returns the value to track. +--- @return A computed signal that tracks the value returned by the getter. +local function toSignal(fn: () -> T): () -> T + local name = debug.info(fn, "n") + return if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then fn else computed(fn) +end + +--- Creates an effect that runs the callback one immediately, and then again +--- when the value returned by the getter changes. The callback receives the new +--- value and the previous value as arguments. +--- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- +--- @param getter A function that returns the value to watch. +--- @param fn The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback +local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () + local tracked = toSignal(getter) + local current: T + return effect(function() + local previous = current + current = tracked() + peek(fn, current, previous) + end) +end + +--- Creates an effect that only runs the callback when the value returned by the +--- getter changes. The callback receives the new value and the previous value +--- as arguments. +--- +--- Effects created within the callback are cleaned up when the listener runs +--- or the listener is disposed. +--- +--- @param getter A function that returns the value to watch. +--- @param fn The function to run when the value changes. +--- @return A function for disposing the effect. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback +local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () + local tracked = toSignal(getter) + local current: T + local firstRun = true + return effect(function() + local previous = current + current = tracked() + if firstRun then + firstRun = false + else + peek(fn, current, previous) + end + end) +end + +--- Disables recursive checks for the current effect or computed signal. Useful +--- for effects that intentionally update signals they depend on. +--- +--- @example +--- ```luau +--- effect(function() +--- recursive() +--- setCount(getCount() + 1) +--- end) +--- ``` +local function recursive() + if activeSub then + activeSub.flags = bit32.band(activeSub.flags, bit32.bnot(RECURSED_CHECK)) + else + warn(debug.traceback("recursive() can only be called inside an effect or computed signal.", 2)) + end +end + +--- 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 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 fn 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 }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () + local innerScopes: { [K]: () -> () } = {} + local disposed = false + + local function updateScopes(data: { [K]: V }) + for key, dispose in innerScopes do + if data[key] == nil then + innerScopes[key] = nil + dispose() + if disposed then + return + end + end + end + + for key, value in data do + if innerScopes[key] == nil then + local disposeScope = effectScope(function() + return fn(value, key) + end, true) + if disposed then + disposeScope() + return + end + innerScopes[key] = disposeScope + end + end + end + + local function disposeScopes() + if not disposed then + disposed = true + for key, dispose in innerScopes do + innerScopes[key] = nil + dispose() + end + end + end + + return effectScope(function() + effect(function() + -- Allow recursive updates in case the callback creates or removes + -- items in the observed table. + recursive() + -- 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()) + end) + + return disposeScopes + end) +end + +--- Creates a new read-only signal that is computed by mapping over the entries +--- of an existing atom. If the key is omitted from the mapper's return value, +--- the original key is preserved. +--- +--- @param getter Returns the table to map over. +--- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. +--- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper +local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } + local currentInput: { [KI]: VI } = {} + local keyCache: { [KI]: KO } = {} + + local function map(value: VI, key: KI): (VO, KO) + local prevSub = setActiveSub(nil) + local success, valueOut, keyOut = pcall(mapper, value, key) + activeSub = prevSub + if not success then + error(`mapped() transform function failed: {valueOut}`) + end + return valueOut, if keyOut == nil then key else keyOut :: any + end + + return computed(function(previousOutput) + local currentOutput: { [KO]: VO } = previousOutput or {} + local previousInput = currentInput + currentInput = getter() + + for keyIn in previousInput do + if currentInput[keyIn] ~= nil then + continue + end + + local prevKeyOut = keyCache[keyIn] + + if prevKeyOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end + currentOutput[prevKeyOut] = nil + keyCache[keyIn] = nil + end + end + + for keyIn, valueIn in currentInput do + if valueIn == previousInput[keyIn] then + continue + end + + local valueOut, keyOut = map(valueIn, keyIn) + + if currentOutput[keyOut] ~= valueOut then + if currentOutput == previousOutput then + currentOutput = table.clone(currentOutput) + end + currentOutput[keyOut] = valueOut + keyCache[keyIn] = keyOut + end + end + + return currentOutput + end) +end + +local reactiveSystem = system.createReactiveSystem({ + update = function(node) + if node.depsTail then + return updateComputed(node :: ComputedNode) + else + return updateSignal(node :: SignalNode) + end + end, + notify = function(effect) + local insertIndex = queuedLength + local firstInsertedIndex = insertIndex + + repeat + effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) + queued[insertIndex] = effect :: EffectNode + insertIndex += 1 + effect = (effect.subs and effect.subs.sub) :: EffectNode + if not effect or bit32.band(effect.flags, WATCHING) == 0 then + break + end + until false + + queuedLength = insertIndex + + insertIndex -= 1 + while firstInsertedIndex < insertIndex do + local left = queued[firstInsertedIndex] + queued[firstInsertedIndex] = queued[insertIndex] + queued[insertIndex] = left + firstInsertedIndex += 1 + insertIndex -= 1 + end + end, + unwatched = function(node) + if bit32.band(node.flags, MUTABLE) == 0 then + effectOper(node :: EffectScopeNode)() + elseif node.depsTail then + node.depsTail = nil + node.flags = bit32.bor(MUTABLE, DIRTY) + purgeDeps(node) + end + end, +}) + +link = reactiveSystem.link +unlink = reactiveSystem.unlink +propagate = reactiveSystem.propagate +checkDirty = reactiveSystem.checkDirty +shallowPropagate = reactiveSystem.shallowPropagate return { - core = core, - globals = globals, - atom = core.atom, - batch = core.batch, - computed = core.computed, - effect = core.effect, - effectScope = core.effectScope, - listen = core.listen, - onCleanup = core.onCleanup, - peek = core.peek, - recursive = core.recursive, - signal = core.signal, - subscribe = core.subscribe, - untracked = core.untracked, - mapped = mapped, + ReactiveFlags = system.ReactiveFlags, + flags = flags, + + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, + + signal = signal, + computed = computed, + effect = effect, + effectScope = effectScope, + trigger = trigger, + atom = atom, + onCleanup = onCleanup, + untracked = untracked, + peek = peek, + batch = batch, + subscribe = subscribe, + listen = listen, + recursive = recursive, observe = observe, - deepFreeze = utils.deepFreeze, + mapped = mapped :: (( + getter: () -> { [KI]: VI }, + mapper: (item: VI, key: KI) -> (VO, KO) + ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), } diff --git a/packages/charm/src/mapped.luau b/packages/charm/src/mapped.luau deleted file mode 100644 index 940a6ae..0000000 --- a/packages/charm/src/mapped.luau +++ /dev/null @@ -1,76 +0,0 @@ -local core = require("./core") - -local computed = core.computed -local setActiveSub = core.setActiveSub - -type Mapped = - & ((getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO }) - & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) - -local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } - local currentInput: { [KI]: VI } = {} - local keyCache: { [KI]: KO } = {} - - local function map(value: VI, key: KI): (VO, KO) - local prevSub = setActiveSub(nil) - local success, valueOut, keyOut = pcall(mapper, value, key) - setActiveSub(prevSub) - - if not success then - error(`mapped() transform function failed: {valueOut}`) - end - - return valueOut, if keyOut == nil then key else keyOut :: any - end - - return computed(function(previousOutput) - local currentOutput: { [KO]: VO } = previousOutput or {} - local previousInput = currentInput - currentInput = getter() - - for keyIn in previousInput do - if currentInput[keyIn] ~= nil then - continue - end - - local prevKeyOut = keyCache[keyIn] - - if prevKeyOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) - end - currentOutput[prevKeyOut] = nil - keyCache[keyIn] = nil - end - end - - for keyIn, valueIn in currentInput do - if valueIn == previousInput[keyIn] then - continue - end - - local valueOut, keyOut = map(valueIn, keyIn) - - if currentOutput[keyOut] ~= valueOut then - if currentOutput == previousOutput then - currentOutput = table.clone(currentOutput) - end - currentOutput[keyOut] = valueOut - keyCache[keyIn] = keyOut - end - end - - return currentOutput - end) -end - ---- Creates a new read-only signal that is computed by mapping over the entries ---- of an existing atom. If the key is omitted from the mapper's return value, ---- the original key is preserved. ---- ---- @param getter Returns the table to map over. ---- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. ---- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper -local mapped = mapped :: Mapped - -return mapped diff --git a/packages/charm/src/observe.luau b/packages/charm/src/observe.luau deleted file mode 100644 index 2a6b9b9..0000000 --- a/packages/charm/src/observe.luau +++ /dev/null @@ -1,75 +0,0 @@ -local core = require("./core") - -local batch = core.batch -local effect = core.effect -local effectScope = core.effectScope -local recursive = core.recursive - ---- 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 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 innerScopes: { [K]: () -> () } = {} - local disposed = false - - local function updateScopes(data: { [K]: V }) - for key, dispose in innerScopes do - if data[key] == nil then - innerScopes[key] = nil - dispose() - if disposed then - return - end - end - end - - 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. - local stopScope = effectScope(function() - return callback(value, key) - end, true) - - if disposed then - return stopScope() - else - innerScopes[key] = stopScope - end - end - end - end - - return effectScope(function() - effect(function() - -- Allow recursive updates in case the callback creates or removes - -- items in the observed table. - recursive() - -- 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()) - end) - - return function() - if not disposed then - disposed = true - for key, dispose in innerScopes do - innerScopes[key] = nil - dispose() - end - end - end - end) -end - -return observe diff --git a/packages/charm/src/alien.luau b/packages/charm/src/system.luau similarity index 99% rename from packages/charm/src/alien.luau rename to packages/charm/src/system.luau index 495a5c3..61834fb 100644 --- a/packages/charm/src/alien.luau +++ b/packages/charm/src/system.luau @@ -90,7 +90,7 @@ local function createReactiveSystem(props: { end local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): () - -- DEVIATION: Do not link signals to sub while peeking + -- DEVIATION: Do not link signals to peeking subscribers if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then return end diff --git a/packages/charm/src/utils.luau b/packages/charm/src/utils.luau deleted file mode 100644 index 367e83c..0000000 --- a/packages/charm/src/utils.luau +++ /dev/null @@ -1,28 +0,0 @@ -local function noYieldHandler(thread: thread, ok: boolean, ...): (boolean, ...any) - if ok and coroutine.status(thread) == "dead" then - return true, ... - elseif not ok then - return false, debug.traceback(thread, tostring((...))) - else - return false, debug.traceback(thread, "Attempted to yield!") - end -end - -local function noYield(callback: (Args...) -> Results..., ...: Args...): (boolean, Results...) - local thread = coroutine.create(callback) - return noYieldHandler(thread, coroutine.resume(thread, ...)) -end - -local function deepFreeze(object: any) - if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then - table.freeze(object) - for _, value in object do - deepFreeze(value) - end - end -end - -return { - noYield = noYield :: typeof(pcall), - deepFreeze = deepFreeze, -} diff --git a/test/spec.server.luau b/test/spec.server.luau index bb1f7aa..dfb6d0b 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -1,8 +1,8 @@ local Charm = require("../packages/charm/src") local suite = require("./suite") -Charm.globals.strict = true -Charm.globals.frozen = true +Charm.flags.strict = true +Charm.flags.frozen = true suite.describe("charm", function() require("../packages/charm/src/__tests__/spec")