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 |
|
| 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. |
|
| 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. |
|
| 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 [`listen()`](#listengetter-callback) for running a subscription once immediately
|
||||||
- Added [`effectScope()`](#effectscopecallback) for collecting and cleaning up multiple effects at once
|
- 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 [`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
|
- The [`computed()`](#computedgetter) callback now gets called with the previous computed value
|
||||||
- Optimized `computed()` to use lazy evaluation instead of eager updates
|
- Optimized `computed()` to use lazy evaluation instead of eager updates
|
||||||
- Optimized `mapped()` to only map values that changed in the original table
|
- 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 computed = Charm.computed
|
||||||
local mapped = Charm.mapped
|
local mapped = Charm.mapped
|
||||||
local setActiveSub = Charm.core.setActiveSub
|
local setActiveSub = Charm.setActiveSub
|
||||||
local signal = Charm.signal
|
local signal = Charm.signal
|
||||||
|
|
||||||
type Entry = { number }
|
type Entry = { number }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ local config = require("./config")
|
||||||
local types = require("./types")
|
local types = require("./types")
|
||||||
local validate = require("./validate")
|
local validate = require("./validate")
|
||||||
|
|
||||||
local globals = Charm.globals
|
local flags = Charm.flags
|
||||||
|
|
||||||
export type None = types.None
|
export type None = types.None
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ local function diff(currentState: any, nextState: any): any
|
||||||
patches = stringifySparseArray(patches)
|
patches = stringifySparseArray(patches)
|
||||||
end
|
end
|
||||||
|
|
||||||
if globals.strict and config.validatePatches then
|
if flags.strict and config.validatePatches then
|
||||||
validate(patches)
|
validate(patches)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,21 @@ local types = require("./types")
|
||||||
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
local globals = Charm.globals
|
|
||||||
|
|
||||||
type SyncStatus = number
|
type SyncStatus = number
|
||||||
|
|
||||||
type ClientKeys = { [string]: SyncStatus }
|
type ClientKeys = { [string]: SyncStatus }
|
||||||
|
|
||||||
type GetterState<T = any> = {
|
type GetterState<T = any> = {
|
||||||
getter: () -> T,
|
getter: () -> T,
|
||||||
currentValue: T,
|
currentValue: T,
|
||||||
previousValue: T,
|
lastSyncedValue: T,
|
||||||
dispose: () -> (),
|
dispose: () -> (),
|
||||||
}
|
}
|
||||||
|
|
||||||
type StateUpdate = {
|
type StateUpdate = {
|
||||||
key: string,
|
key: string,
|
||||||
currentValue: any,
|
newValue: any,
|
||||||
previousValue: any,
|
oldValue: any,
|
||||||
}
|
}
|
||||||
|
|
||||||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||||
|
|
@ -46,22 +45,19 @@ local function startTracking<T>(key: string, getter: () -> T)
|
||||||
local trackedState = {
|
local trackedState = {
|
||||||
getter = getter,
|
getter = getter,
|
||||||
currentValue = value,
|
currentValue = value,
|
||||||
previousValue = value,
|
lastSyncedValue = value,
|
||||||
}
|
}
|
||||||
|
|
||||||
trackedState.dispose = Charm.effect(function()
|
trackedState.dispose = Charm.subscribe(getter, function(newValue, oldValue)
|
||||||
local newValue = getter()
|
|
||||||
|
|
||||||
-- When preserving history, we need to keep track of previous values
|
-- When preserving history, we need to keep track of previous values
|
||||||
-- so we can create granular patches for each change.
|
-- so we can create granular patches for each change.
|
||||||
if config.preserveHistory and trackedState.currentValue ~= newValue then
|
if config.preserveHistory then
|
||||||
table.insert(globalUpdates, {
|
table.insert(globalUpdates, {
|
||||||
key = key,
|
key = key,
|
||||||
currentValue = newValue,
|
newValue = newValue,
|
||||||
previousValue = trackedState.previousValue,
|
oldValue = oldValue,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
trackedState.currentValue = newValue
|
trackedState.currentValue = newValue
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -69,9 +65,9 @@ local function startTracking<T>(key: string, getter: () -> T)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function stopTracking(key: string)
|
local function stopTracking(key: string)
|
||||||
local tracker = trackedGetters[key]
|
local trackedState = trackedGetters[key]
|
||||||
if tracker then
|
if trackedState then
|
||||||
tracker.dispose()
|
trackedState.dispose()
|
||||||
trackedGetters[key] = nil
|
trackedGetters[key] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -97,7 +93,7 @@ local function diffGlobalUpdateBuffer(): { any }
|
||||||
local patches = table.create(patchCount)
|
local patches = table.create(patchCount)
|
||||||
|
|
||||||
for index, update in globalUpdates do
|
for index, update in globalUpdates do
|
||||||
patches[index] = patch.diff(update.previousValue, update.currentValue)
|
patches[index] = patch.diff(update.oldValue, update.newValue)
|
||||||
end
|
end
|
||||||
|
|
||||||
return patches
|
return patches
|
||||||
|
|
@ -132,7 +128,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
||||||
elseif status == PENDING_INITIAL_STATE then
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
table.insert(payloads, {
|
table.insert(payloads, {
|
||||||
type = "init",
|
type = "init",
|
||||||
data = { [key] = update.currentValue },
|
data = { [key] = update.newValue },
|
||||||
})
|
})
|
||||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
|
|
@ -143,10 +139,10 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
||||||
local initData: { [string]: any } = {}
|
local initData: { [string]: any } = {}
|
||||||
|
|
||||||
for key, status in clientKeys do
|
for key, status in clientKeys do
|
||||||
local getterState = trackedGetters[key]
|
local trackedState = trackedGetters[key]
|
||||||
|
|
||||||
if status == PENDING_INITIAL_STATE and getterState then
|
if status == PENDING_INITIAL_STATE and trackedState then
|
||||||
initData[key] = getterState.currentValue
|
initData[key] = trackedState.currentValue
|
||||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -162,10 +158,10 @@ local function diffGlobalState(): any
|
||||||
local currentGlobalState: { [string]: any } = {}
|
local currentGlobalState: { [string]: any } = {}
|
||||||
local previousGlobalState: { [string]: any } = {}
|
local previousGlobalState: { [string]: any } = {}
|
||||||
|
|
||||||
for key, tracker in trackedGetters do
|
for key, trackedState in trackedGetters do
|
||||||
currentGlobalState[key] = tracker.currentValue
|
currentGlobalState[key] = trackedState.currentValue
|
||||||
previousGlobalState[key] = tracker.previousValue
|
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||||
tracker.previousValue = tracker.currentValue
|
trackedState.lastSyncedValue = trackedState.currentValue
|
||||||
end
|
end
|
||||||
|
|
||||||
return patch.diff(previousGlobalState, currentGlobalState)
|
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
|
-- Create patches for initialized signals that changed, and add signals
|
||||||
-- that are still pending initial state to the init payload.
|
-- that are still pending initial state to the init payload.
|
||||||
for key, status in clientKeys do
|
for key, status in clientKeys do
|
||||||
local getterState = trackedGetters[key]
|
local trackedState = trackedGetters[key]
|
||||||
|
|
||||||
if status == LISTENING_FOR_CHANGES then
|
if status == LISTENING_FOR_CHANGES then
|
||||||
patchData[key] = globalPatch[key]
|
patchData[key] = globalPatch[key]
|
||||||
elseif status == PENDING_INITIAL_STATE then
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
initData[key] = getterState.currentValue
|
initData[key] = trackedState.currentValue
|
||||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -273,12 +269,6 @@ local function flush()
|
||||||
local granular = config.preserveHistory
|
local granular = config.preserveHistory
|
||||||
local globalPatch = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
|
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
|
for client in clients do
|
||||||
local payloads = if granular
|
local payloads = if granular
|
||||||
then selectFromGlobalPatchList(client, globalPatch)
|
then selectFromGlobalPatchList(client, globalPatch)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
local core = require("../core")
|
local Charm = require("../")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local computed = core.computed
|
local computed = Charm.computed
|
||||||
local effect = core.effect
|
local effect = Charm.effect
|
||||||
local signal = core.signal
|
local signal = Charm.signal
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should correctly propagate changes through computed core", function()
|
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 suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local computed = core.computed
|
local computed = Charm.computed
|
||||||
local effect = core.effect
|
local effect = Charm.effect
|
||||||
local effectScope = core.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local endBatch = core.endBatch
|
local endBatch = Charm.endBatch
|
||||||
local onCleanup = core.onCleanup
|
local onCleanup = Charm.onCleanup
|
||||||
local recursive = core.recursive
|
local recursive = Charm.recursive
|
||||||
local setActiveSub = core.setActiveSub
|
local setActiveSub = Charm.setActiveSub
|
||||||
local signal = core.signal
|
local signal = Charm.signal
|
||||||
local startBatch = core.startBatch
|
local startBatch = Charm.startBatch
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should clear subscriptions when untracked by all subscribers", function()
|
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 suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local effect = core.effect
|
local effect = Charm.effect
|
||||||
local effectScope = core.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local signal = core.signal
|
local signal = Charm.signal
|
||||||
local onCleanup = core.onCleanup
|
local onCleanup = Charm.onCleanup
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should not trigger after stop", function()
|
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")
|
assert(cleanups == 2, "should not cleanup after update")
|
||||||
end)
|
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 {}
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
local core = require("../core")
|
local Charm = require("../")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local atom = core.atom
|
local atom = Charm.atom
|
||||||
local computed = core.computed
|
local computed = Charm.computed
|
||||||
local effect = core.effect
|
local effect = Charm.effect
|
||||||
local effectScope = core.effectScope
|
local effectScope = Charm.effectScope
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
--- Creates a weak reference to a value.
|
--- Creates a weak reference to a value.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
local core = require("../core")
|
local Charm = require("../")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local signal = core.signal
|
local signal = Charm.signal
|
||||||
local effect = core.effect
|
local effect = Charm.effect
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should accept equality function", function()
|
test("should accept equality function", function()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ end)
|
||||||
suite.describe("topology", function()
|
suite.describe("topology", function()
|
||||||
require("./topology.spec")
|
require("./topology.spec")
|
||||||
end)
|
end)
|
||||||
|
suite.describe("trigger", function()
|
||||||
|
require("./trigger.spec")
|
||||||
|
end)
|
||||||
suite.describe("untracked", function()
|
suite.describe("untracked", function()
|
||||||
require("./untracked.spec")
|
require("./untracked.spec")
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
local core = require("../core")
|
local Charm = require("../")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local atom = core.atom
|
local atom = Charm.atom
|
||||||
local computed = core.computed
|
local computed = Charm.computed
|
||||||
local effect = core.effect
|
local effect = Charm.effect
|
||||||
local describe = suite.describe
|
local describe = suite.describe
|
||||||
local test = suite.test
|
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,
|
|
||||||
}
|
|
||||||
134
packages/charm/src/index.d.ts
vendored
134
packages/charm/src/index.d.ts
vendored
|
|
@ -1,24 +1,48 @@
|
||||||
type Key = string | number | symbol;
|
type Key = string | number | symbol;
|
||||||
type Cleanup = () => void;
|
type Cleanup = () => void;
|
||||||
|
|
||||||
/**
|
export enum ReactiveFlags {
|
||||||
* A reactive signal that acts as both a getter and setter. Calling the signal
|
None = 0b0000000,
|
||||||
* with an argument sets its value, while calling it with no arguments returns
|
Mutable = 0b0000001,
|
||||||
* its current value.
|
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> {
|
export interface Atom<T> {
|
||||||
(newValue: T | ((currentValue: T) => T)): T;
|
(newValue: T | ((currentValue: T) => T)): T;
|
||||||
(): T;
|
(): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Getter<T> = () => T;
|
|
||||||
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
|
export type Setter<T> = (newValue: T | ((currentValue: T) => T)) => T;
|
||||||
|
|
||||||
export type Equals<T> = (current: T, incoming: T) => boolean;
|
export type Equals<T> = (current: T, incoming: T) => boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global flags that modify the behavior of Charm's reactive system.
|
* 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.
|
* Enforces synchronous, non-yielding behavior in signals and effects.
|
||||||
* Also enables state validation in Charm Sync to catch sync errors early.
|
* 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.
|
* @returns A function for disposing the effect.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
|
* @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
|
* 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.
|
* @returns A function for disposing all effects created within the scope.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
|
* @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
|
* 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
|
* there is no active effect, a warning is logged, unless `failSilently` is
|
||||||
* set to `true`.
|
* 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`.
|
* @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
|
* @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.
|
* effects in the parent effect or scope.
|
||||||
*
|
*
|
||||||
* For an alternative that still cleans up inner effects, use `peek` instead.
|
* For an alternative that still cleans up inner effects, use `peek` instead.
|
||||||
*
|
*
|
||||||
* @param callback The callback function.
|
* @param fn A function that may read signals or create effects.
|
||||||
* @param args Arguments to pass to the callback.
|
* @param args Arguments to pass to the function.
|
||||||
* @returns The return value of the callback.
|
* @returns The return value of the function.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
* @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.
|
* inner effects so that they dispose with the parent effect.
|
||||||
*
|
*
|
||||||
* To avoid capturing inner effects, use `untracked` instead.
|
* To avoid capturing inner effects, use `untracked` instead.
|
||||||
*
|
*
|
||||||
* @param callback The callback function.
|
* @param fn A function that may read signals.
|
||||||
* @param args Arguments to pass to the callback.
|
* @param args Arguments to pass to the function.
|
||||||
* @returns The return value of the callback.
|
* @returns The return value of the function.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
|
* @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
|
* 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 fn A function that performs multiple updates.
|
||||||
* @param args Arguments to pass to the callback.
|
* @param args Arguments to pass to the function.
|
||||||
* @returns The return value of the callback.
|
* @returns The return value of the function.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
|
* @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
|
* 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.
|
* or the listener is disposed.
|
||||||
*
|
*
|
||||||
* @param getter A function that returns the value to watch.
|
* @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.
|
* @returns A function for disposing the effect.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
|
* @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
|
* 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.
|
* or the listener is disposed.
|
||||||
*
|
*
|
||||||
* @param getter A function that returns the value to watch.
|
* @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.
|
* @returns A function for disposing the effect.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
|
* @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
|
* 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.
|
* from the table or when the callback is disposed.
|
||||||
*
|
*
|
||||||
* @param getter A function that returns the table to observe.
|
* @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.
|
* @returns A function for disposing the observer.
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
|
||||||
*/
|
*/
|
||||||
export function observe<V, K = number>(
|
export function observe<V, K = number>(
|
||||||
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
|
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
|
||||||
callback: (value: V, key: K) => Cleanup | void,
|
fn: (value: V, key: K) => Cleanup | void,
|
||||||
): Cleanup;
|
): Cleanup;
|
||||||
// Overload for objects
|
// Overload for objects
|
||||||
export function observe<V, K extends Key>(
|
export function observe<V, K extends Key>(
|
||||||
getter: () => { readonly [K in Key]: V },
|
getter: () => { readonly [K in Key]: V },
|
||||||
callback: (value: V, key: K) => Cleanup | void,
|
fn: (value: V, key: K) => Cleanup | void,
|
||||||
): Cleanup;
|
): 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.
|
* Disables recursive checks for the current effect or computed signal.
|
||||||
* Useful for effects that intentionally update signals they depend on.
|
* 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 setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined;
|
||||||
|
|
||||||
export function getBatchDepth(): number;
|
|
||||||
|
|
||||||
export function startBatch(): void;
|
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")
|
Reactive system adapted from alien-signals at
|
||||||
local mapped = require("@self/mapped")
|
https://github.com/stackblitz/alien-signals/blob/master/src/index.ts
|
||||||
local observe = require("@self/observe")
|
|
||||||
local utils = require("@self/utils")
|
|
||||||
|
|
||||||
type Update<T> = ((previous: T) -> T) | T
|
MIT License
|
||||||
|
|
||||||
--- A reactive signal that acts as both a getter and setter. Calling the signal
|
Copyright (c) 2024-present Johnson Chu
|
||||||
--- with an argument sets its value, while calling it with no arguments returns
|
|
||||||
--- its current value.
|
|
||||||
export type Atom<T = any> = (...Update<T>) -> T
|
|
||||||
|
|
||||||
--- A getter-only reactive signal. Calling the signal with no arguments returns
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
--- its current value. It cannot be set directly.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
export type Getter<T = any> = () -> T
|
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
|
The above copyright notice and this permission notice shall be included in all
|
||||||
--- value. It cannot be read directly.
|
copies or substantial portions of the Software.
|
||||||
export type Setter<T = any> = (...Update<T>) -> ()
|
|
||||||
|
|
||||||
export type ReactiveNode = core.ReactiveNode
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
export type Link = core.Link
|
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 {
|
return {
|
||||||
core = core,
|
ReactiveFlags = system.ReactiveFlags,
|
||||||
globals = globals,
|
flags = flags,
|
||||||
atom = core.atom,
|
|
||||||
batch = core.batch,
|
getActiveSub = getActiveSub,
|
||||||
computed = core.computed,
|
setActiveSub = setActiveSub,
|
||||||
effect = core.effect,
|
startBatch = startBatch,
|
||||||
effectScope = core.effectScope,
|
endBatch = endBatch,
|
||||||
listen = core.listen,
|
|
||||||
onCleanup = core.onCleanup,
|
signal = signal,
|
||||||
peek = core.peek,
|
computed = computed,
|
||||||
recursive = core.recursive,
|
effect = effect,
|
||||||
signal = core.signal,
|
effectScope = effectScope,
|
||||||
subscribe = core.subscribe,
|
trigger = trigger,
|
||||||
untracked = core.untracked,
|
atom = atom,
|
||||||
mapped = mapped,
|
onCleanup = onCleanup,
|
||||||
|
untracked = untracked,
|
||||||
|
peek = peek,
|
||||||
|
batch = batch,
|
||||||
|
subscribe = subscribe,
|
||||||
|
listen = listen,
|
||||||
|
recursive = recursive,
|
||||||
observe = observe,
|
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
|
end
|
||||||
|
|
||||||
local function link(dep: ReactiveNode, sub: ReactiveNode, version: number): ()
|
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
|
if bit32.btest(sub.flags, PEEKING) and bit32.btest(dep.flags, MUTABLE) then
|
||||||
return
|
return
|
||||||
end
|
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 Charm = require("../packages/charm/src")
|
||||||
local suite = require("./suite")
|
local suite = require("./suite")
|
||||||
|
|
||||||
Charm.globals.strict = true
|
Charm.flags.strict = true
|
||||||
Charm.globals.frozen = true
|
Charm.flags.frozen = true
|
||||||
|
|
||||||
suite.describe("charm", function()
|
suite.describe("charm", function()
|
||||||
require("../packages/charm/src/__tests__/spec")
|
require("../packages/charm/src/__tests__/spec")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue