feat: add trigger(), refactor source

This commit is contained in:
littensy 2025-11-12 16:28:41 -08:00
parent fdc598f6e6
commit 898fbdba29
21 changed files with 1119 additions and 1039 deletions

View file

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

View file

@ -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 }

View file

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

View file

@ -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<T = any> = {
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<T>(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<T>(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)

View file

@ -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()

View file

@ -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()

View file

@ -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 {}

View file

@ -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.

View file

@ -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()

View file

@ -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)

View file

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

View file

@ -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 {}

View file

@ -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<T = any> = ReactiveNode & {
value: T?,
getter: (previousValue: T?) -> T,
}
type Signal<T = any> = 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<T>(this: Computed<T>): () -> 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<T>(
this: Signal<T>,
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<T>(
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<T>(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<T>(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<Args..., Results...>(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<Args..., Results...>(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<Args..., Results...>(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<T>(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<T>(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<T>(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,
}

View file

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

View file

@ -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<T> {
(newValue: T | ((currentValue: T) => T)): T;
(): T;
}
export type Getter<T> = () => T;
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
export type Equals<T> = (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<T>(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<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
export function untracked<Args extends any[], Result>(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<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
export function peek<Args extends any[], Result>(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<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
export function batch<Args extends any[], Result>(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<Args extends any[], Result>(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<T>(getter: () => T, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
export function listen<T>(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<T>(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<T>(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup;
export function subscribe<T>(getter: () => T, fn: (value: T, previousValue: T) => void): Cleanup;
/**
* Creates a new read-only signal that is computed by mapping over the entries
@ -219,52 +252,20 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
* 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<V, K = number>(
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
callback: (value: V, key: K) => Cleanup | void,
fn: (value: V, key: K) => Cleanup | void,
): Cleanup;
// Overload for objects
export function observe<V, K extends Key>(
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 };
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;
}
/**
* Disables recursive checks for the current effect or computed signal.
* Useful for effects that intentionally update signals they depend on.
@ -283,9 +284,6 @@ export namespace core {
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
export function getBatchDepth(): number;
export function startBatch(): void;
export function endBatch(): void;
}

View file

@ -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<T> = ((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<T = any> = (...Update<T>) -> 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 = any> = () -> 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<T = any> = (...Update<T>) -> ()
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 = any> = (...((T) -> T) | T) -> T
type EffectScopeNode = ReactiveNode & {
cleanups: { () -> () },
}
type EffectNode = ReactiveNode & {
fn: () -> ...(() -> ())?,
cleanups: { () -> () },
}
type ComputedNode<T = any> = ReactiveNode & {
value: T?,
getter: (previousValue: T?) -> T,
}
type SignalNode<T = any> = 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<Args..., Results...>(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<T>(this: ComputedNode<T>): () -> 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<T>(
this: SignalNode<T>,
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<T>(
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<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom<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<T>(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<Args..., Results...>(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<Args..., Results...>(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<Args..., Results...>(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<T>(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<T>(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<T>(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<K, V>(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<KI, VI, KO, VO>(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 :: (<KI, VI, KO, VO>(
getter: () -> { [KI]: VI },
mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
}

View file

@ -1,76 +0,0 @@
local core = require("./core")
local computed = core.computed
local setActiveSub = core.setActiveSub
type Mapped =
& (<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)) -> () -> { [KO]: VO })
& (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO })
local function mapped<KI, VI, KO, VO>(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

View file

@ -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<K, V>(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

View file

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

View file

@ -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<Args..., Results...>(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,
}

View file

@ -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")