mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add trigger(), refactor source
This commit is contained in:
parent
fdc598f6e6
commit
898fbdba29
21 changed files with 1119 additions and 1039 deletions
50
README.md
50
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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
85
packages/charm/src/__tests__/trigger.spec.luau
Normal file
85
packages/charm/src/__tests__/trigger.spec.luau
Normal 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 {}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
168
packages/charm/src/index.d.ts
vendored
168
packages/charm/src/index.d.ts
vendored
|
|
@ -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,73 +252,38 @@ 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 };
|
||||
/**
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue