mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 21:01:38 +00:00
feat: add signal api
This commit is contained in:
parent
bf92413a97
commit
1e0b10aef6
21 changed files with 300 additions and 293 deletions
|
|
@ -1,10 +1,10 @@
|
||||||
local Charm = require("../packages/charm/src")
|
local Charm = require("../packages/charm/src")
|
||||||
local bench = require("./bench")
|
local bench = require("./bench")
|
||||||
|
|
||||||
local atom = Charm.atom
|
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local mapped = Charm.mapped
|
local mapped = Charm.mapped
|
||||||
local setCurrentSub = Charm.core.setCurrentSub
|
local setActiveSub = Charm.core.setActiveSub
|
||||||
|
local signal = Charm.signal
|
||||||
|
|
||||||
type Entry = { number }
|
type Entry = { number }
|
||||||
|
|
||||||
|
|
@ -25,9 +25,9 @@ local function simpleMapped(source: () -> { Entry }, mapper: (Entry, number) ->
|
||||||
local nextState = {}
|
local nextState = {}
|
||||||
|
|
||||||
for index, value in state do
|
for index, value in state do
|
||||||
local currentSub = setCurrentSub(nil)
|
local currentSub = setActiveSub(nil)
|
||||||
local success, valueOut, keyOut = pcall(mapper, value, index)
|
local success, valueOut, keyOut = pcall(mapper, value, index)
|
||||||
setCurrentSub(currentSub)
|
setActiveSub(currentSub)
|
||||||
|
|
||||||
if success then
|
if success then
|
||||||
nextState[keyOut] = valueOut
|
nextState[keyOut] = valueOut
|
||||||
|
|
@ -52,26 +52,26 @@ end
|
||||||
|
|
||||||
for _, percent in { 0.1, 0.5, 1 } do
|
for _, percent in { 0.1, 0.5, 1 } do
|
||||||
bench.run(`mapped {percent}`, function()
|
bench.run(`mapped {percent}`, function()
|
||||||
local src = atom(initialState)
|
local src, setSrc = signal(initialState)
|
||||||
local out = mapped(src, mapper)
|
local out = mapped(src, mapper)
|
||||||
|
|
||||||
bench.profilebegin("init")
|
bench.profilebegin("init")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
src(updatePercentage(percent))
|
setSrc(updatePercentage(percent))
|
||||||
bench.profilebegin("update")
|
bench.profilebegin("update")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
bench.run(`control {percent}`, function()
|
bench.run(`control {percent}`, function()
|
||||||
local src = atom(initialState)
|
local src, setSrc = signal(initialState)
|
||||||
local out = simpleMapped(src, mapper)
|
local out = simpleMapped(src, mapper)
|
||||||
|
|
||||||
bench.profilebegin("init")
|
bench.profilebegin("init")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
src(updatePercentage(percent))
|
setSrc(updatePercentage(percent))
|
||||||
bench.profilebegin("update")
|
bench.profilebegin("update")
|
||||||
out()
|
out()
|
||||||
bench.profileend()
|
bench.profileend()
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
local Charm = require("../packages/charm/src")
|
local Charm = require("../packages/charm/src")
|
||||||
local bench = require("./bench")
|
local bench = require("./bench")
|
||||||
|
|
||||||
local atom = Charm.atom
|
|
||||||
local computed = Charm.computed
|
local computed = Charm.computed
|
||||||
local effect = Charm.effect
|
local effect = Charm.effect
|
||||||
local listen = Charm.listen
|
local listen = Charm.listen
|
||||||
|
local signal = Charm.signal
|
||||||
|
|
||||||
for _, w in { 10, 100 } do
|
for _, w in { 10, 100 } do
|
||||||
for _, h in { 10, 100 } do
|
for _, h in { 10, 100 } do
|
||||||
bench.run(`propagate {w}*{h}`, function()
|
bench.run(`propagate {w}*{h}`, function()
|
||||||
local src = atom(1)
|
local src, setSrc = signal(1)
|
||||||
for _ = 1, w do
|
for _ = 1, w do
|
||||||
local last = src
|
local last = src
|
||||||
for _ = 1, h do
|
for _ = 1, h do
|
||||||
|
|
@ -22,7 +22,7 @@ for _, w in { 10, 100 } do
|
||||||
last()
|
last()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
src(src() + 1)
|
setSrc(src() + 1)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -30,7 +30,7 @@ end
|
||||||
for _, w in { 10, 100 } do
|
for _, w in { 10, 100 } do
|
||||||
for _, h in { 10, 100 } do
|
for _, h in { 10, 100 } do
|
||||||
bench.run(`propagate (listen) {w}*{h}`, function()
|
bench.run(`propagate (listen) {w}*{h}`, function()
|
||||||
local src = atom(1)
|
local src, setSrc = signal(1)
|
||||||
for _ = 1, w do
|
for _ = 1, w do
|
||||||
local last = src
|
local last = src
|
||||||
for _ = 1, h do
|
for _ = 1, h do
|
||||||
|
|
@ -41,7 +41,7 @@ for _, w in { 10, 100 } do
|
||||||
end
|
end
|
||||||
listen(last, function() end)
|
listen(last, function() end)
|
||||||
end
|
end
|
||||||
src(src() + 1)
|
setSrc(src() + 1)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||||
end
|
end
|
||||||
|
|
||||||
afterEach(function()
|
afterEach(function()
|
||||||
config.autoSerialize = true
|
config.fixArrays = true
|
||||||
config.validatePatches = true
|
config.validatePatches = true
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -104,13 +104,13 @@ test("cannot preserve sparse arrays", function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("optionally fixes sparse arrays", function()
|
test("optionally fixes sparse arrays", function()
|
||||||
config.autoSerialize = true
|
config.fixArrays = true
|
||||||
|
|
||||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||||
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
||||||
end
|
end
|
||||||
|
|
||||||
config.autoSerialize = false
|
config.fixArrays = false
|
||||||
|
|
||||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||||
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ local function syncAtoms(atoms: { [string]: Charm.Atom })
|
||||||
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
||||||
continue
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
atomRegister[key] = atom
|
atomRegister[key] = atom
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ return {
|
||||||
--- to address remote event argument limitations. Defaults to `true`.
|
--- to address remote event argument limitations. Defaults to `true`.
|
||||||
--- This option should be disabled if your network library uses a custom
|
--- This option should be disabled if your network library uses a custom
|
||||||
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
|
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||||
autoSerialize = true,
|
fixArrays = true,
|
||||||
--- When `true`, patches will be validated before being sent to clients to
|
--- When `true`, patches will be validated before being sent to clients to
|
||||||
--- enforce remote event limitations. Only checked in strict mode and if
|
--- enforce remote event limitations. Only checked in strict mode and if
|
||||||
--- `autoSerialize` is enabled. Defaults to `true`.
|
--- `fixArrays` is enabled. Defaults to `true`.
|
||||||
validatePatches = true,
|
validatePatches = true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
packages/charm-sync/src/index.d.ts
vendored
11
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1,7 +1,5 @@
|
||||||
import { Atom } from "@rbxts/charm";
|
import { Atom } from "@rbxts/charm";
|
||||||
|
|
||||||
type Cleanup = () => void;
|
|
||||||
|
|
||||||
type Key = string | number | symbol;
|
type Key = string | number | symbol;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,7 +16,7 @@ type SelectorMap = Record<string, () => any>;
|
||||||
/**
|
/**
|
||||||
* Infers the type of the return values produced by a map of functions.
|
* Infers the type of the return values produced by a map of functions.
|
||||||
*/
|
*/
|
||||||
type StateOfMap<T> = {
|
export type StateOfMap<T> = {
|
||||||
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
|
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -88,11 +86,11 @@ export const config: {
|
||||||
*
|
*
|
||||||
* @server
|
* @server
|
||||||
*/
|
*/
|
||||||
autoSerialize: boolean;
|
fixArrays: boolean;
|
||||||
/**
|
/**
|
||||||
* When `true`, patches will be validated before being sent to clients to
|
* When `true`, patches will be validated before being sent to clients to
|
||||||
* enforce remote event limitations. Only checked in strict mode and if
|
* enforce remote event limitations. Only checked in strict mode and if
|
||||||
* `autoSerialize` is enabled. Defaults to `true`.
|
* `fixArrays` is enabled. Defaults to `true`.
|
||||||
*
|
*
|
||||||
* @server
|
* @server
|
||||||
*/
|
*/
|
||||||
|
|
@ -178,11 +176,10 @@ export namespace server {
|
||||||
* be sent to clients whenever the atoms are updated.
|
* be sent to clients whenever the atoms are updated.
|
||||||
*
|
*
|
||||||
* @param onSync The function to call when patches can be sent to a client.
|
* @param onSync The function to call when patches can be sent to a client.
|
||||||
* @returns `stopSync()`
|
|
||||||
*/
|
*/
|
||||||
export function startSync<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean = true>(
|
export function startSync<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean = true>(
|
||||||
onSync: (client: Player, payloads: SyncPayload<Atoms, Serialize>[]) => void,
|
onSync: (client: Player, payloads: SyncPayload<Atoms, Serialize>[]) => void,
|
||||||
): Cleanup;
|
): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops automatically syncing registered atoms to clients. Patches will
|
* Stops automatically syncing registered atoms to clients. Patches will
|
||||||
|
|
|
||||||
|
|
@ -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 flags = Charm.flags
|
local globals = Charm.globals
|
||||||
|
|
||||||
export type None = types.None
|
export type None = types.None
|
||||||
|
|
||||||
|
|
@ -73,13 +73,13 @@ local function diff(currentState: any, nextState: any): any
|
||||||
patches[key] = diff(previous, nextState[key])
|
patches[key] = diff(previous, nextState[key])
|
||||||
end
|
end
|
||||||
|
|
||||||
if config.autoSerialize then
|
if config.fixArrays then
|
||||||
-- Coerce sparse array patches into dictionaries
|
-- Coerce sparse array patches into dictionaries
|
||||||
if currentState[1] ~= nil or nextState[1] ~= nil then
|
if currentState[1] ~= nil or nextState[1] ~= nil then
|
||||||
patches = stringifySparseArray(patches)
|
patches = stringifySparseArray(patches)
|
||||||
end
|
end
|
||||||
|
|
||||||
if flags.strict and config.validatePatches then
|
if globals.strict and config.validatePatches then
|
||||||
for key, value in patches do
|
for key, value in patches do
|
||||||
validate(value, key)
|
validate(value, key)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ local types = require("./types")
|
||||||
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
|
local globals = Charm.globals
|
||||||
|
|
||||||
type RegisteredAtom<T = any> = {
|
type RegisteredAtom<T = any> = {
|
||||||
atom: () -> T,
|
callback: () -> T,
|
||||||
value: T,
|
currentValue: T,
|
||||||
valueLastSynced: T,
|
previousValue: T,
|
||||||
stop: () -> (),
|
dispose: () -> (),
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncStatus = number
|
type SyncStatus = number
|
||||||
|
|
@ -19,82 +21,82 @@ local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||||
|
|
||||||
local atomRegister: { [string]: RegisteredAtom } = {}
|
local atomRegister: { [string]: RegisteredAtom } = {}
|
||||||
local clientRegister: { [any]: { [string]: SyncStatus } } = {}
|
local clientRegister: { [any]: { [string]: SyncStatus } } = {}
|
||||||
local globalUpdates: { { key: string, value: any, previousValue: any } } = {}
|
local globalUpdates: { { key: string, currentValue: any, previousValue: any } } = {}
|
||||||
|
|
||||||
local connection: RBXScriptConnection?
|
local connection: RBXScriptConnection?
|
||||||
local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
||||||
|
|
||||||
local function registerAtom<T>(key: string, atom: () -> T)
|
local function syncGlobalAtom<T>(key: string, callback: () -> T)
|
||||||
if atomRegister[key] then
|
if atomRegister[key] then
|
||||||
if atomRegister[key].atom ~= atom then
|
if atomRegister[key].callback ~= callback then
|
||||||
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local registeredAtom: RegisteredAtom<T> = {
|
local registeredAtom: RegisteredAtom<T> = {
|
||||||
atom = atom,
|
callback = callback,
|
||||||
value = atom(),
|
currentValue = callback(),
|
||||||
valueLastSynced = atom(),
|
previousValue = callback(),
|
||||||
stop = function() end,
|
dispose = function() end,
|
||||||
}
|
}
|
||||||
|
|
||||||
registeredAtom.stop = Charm.effect(function()
|
registeredAtom.dispose = Charm.effect(function()
|
||||||
local value = atom()
|
local newValue = callback()
|
||||||
|
|
||||||
-- 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 registeredAtom.value ~= value then
|
if config.preserveHistory and registeredAtom.currentValue ~= newValue then
|
||||||
table.insert(globalUpdates, {
|
table.insert(globalUpdates, {
|
||||||
key = key,
|
key = key,
|
||||||
value = value,
|
currentValue = newValue,
|
||||||
previousValue = registeredAtom.value,
|
previousValue = registeredAtom.currentValue,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
registeredAtom.value = value
|
registeredAtom.currentValue = newValue
|
||||||
end)
|
end)
|
||||||
|
|
||||||
atomRegister[key] = registeredAtom
|
atomRegister[key] = registeredAtom
|
||||||
end
|
end
|
||||||
|
|
||||||
local function unregisterAtom(key: string)
|
local function unsyncGlobalAtom(key: string)
|
||||||
local registeredAtom = atomRegister[key]
|
local registeredAtom = atomRegister[key]
|
||||||
|
|
||||||
if registeredAtom then
|
if registeredAtom then
|
||||||
registeredAtom.stop()
|
registeredAtom.dispose()
|
||||||
atomRegister[key] = nil
|
atomRegister[key] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function unregisterUnusedAtoms()
|
local function unsyncStaleGlobalAtoms()
|
||||||
local used: { [string]: boolean } = {}
|
local active: { [string]: boolean } = {}
|
||||||
|
|
||||||
for _, clientAtoms in clientRegister do
|
for _, clientAtoms in clientRegister do
|
||||||
for key in clientAtoms do
|
for key in clientAtoms do
|
||||||
used[key] = true
|
active[key] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for key in atomRegister do
|
for key in atomRegister do
|
||||||
if not used[key] then
|
if not active[key] then
|
||||||
unregisterAtom(key)
|
unsyncGlobalAtom(key)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createHistoricalPatches(): { any }
|
local function createGlobalPatchHistory(): { any }
|
||||||
local patchCount = #globalUpdates
|
local patchCount = #globalUpdates
|
||||||
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.value)
|
patches[index] = patch.diff(update.previousValue, update.currentValue)
|
||||||
end
|
end
|
||||||
|
|
||||||
return patches
|
return patches
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload }
|
local function createPayloadHistoryForClient(client: any, patches: { any }): { SyncPayload }
|
||||||
local syncedAtoms = clientRegister[client]
|
local syncedAtoms = clientRegister[client]
|
||||||
|
|
||||||
if not syncedAtoms then
|
if not syncedAtoms then
|
||||||
|
|
@ -123,9 +125,8 @@ local function createHistoricalPayloadsForClient(client: any, patches: { 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.value },
|
data = { [key] = update.currentValue },
|
||||||
})
|
})
|
||||||
|
|
||||||
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -138,7 +139,7 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }):
|
||||||
local registeredAtom = atomRegister[key]
|
local registeredAtom = atomRegister[key]
|
||||||
|
|
||||||
if status == PENDING_INITIAL_STATE and registeredAtom then
|
if status == PENDING_INITIAL_STATE and registeredAtom then
|
||||||
initPayload[key] = registeredAtom.value
|
initPayload[key] = registeredAtom.currentValue
|
||||||
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -150,20 +151,20 @@ local function createHistoricalPayloadsForClient(client: any, patches: { any }):
|
||||||
return payloads
|
return payloads
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createPatch(): any
|
local function createGlobalPatch(): any
|
||||||
local current: { [string]: any } = {}
|
local current: { [string]: any } = {}
|
||||||
local previous: { [string]: any } = {}
|
local previous: { [string]: any } = {}
|
||||||
|
|
||||||
for key, registeredAtom in atomRegister do
|
for key, registeredAtom in atomRegister do
|
||||||
current[key] = registeredAtom.value
|
current[key] = registeredAtom.currentValue
|
||||||
previous[key] = registeredAtom.valueLastSynced
|
previous[key] = registeredAtom.previousValue
|
||||||
registeredAtom.valueLastSynced = registeredAtom.value
|
registeredAtom.previousValue = registeredAtom.currentValue
|
||||||
end
|
end
|
||||||
|
|
||||||
return patch.diff(previous, current)
|
return patch.diff(previous, current)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createPayloadsForClient(client: any, patchData: any): { SyncPayload }
|
local function createPayloadForClient(client: any, patchData: any): { SyncPayload }
|
||||||
local syncedAtoms = clientRegister[client]
|
local syncedAtoms = clientRegister[client]
|
||||||
local payloads: { SyncPayload } = {}
|
local payloads: { SyncPayload } = {}
|
||||||
|
|
||||||
|
|
@ -182,7 +183,7 @@ local function createPayloadsForClient(client: any, patchData: any): { SyncPaylo
|
||||||
if status == LISTENING_FOR_CHANGES then
|
if status == LISTENING_FOR_CHANGES then
|
||||||
patchPayload[key] = patchData[key]
|
patchPayload[key] = patchData[key]
|
||||||
elseif status == PENDING_INITIAL_STATE then
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
initPayload[key] = registeredAtom.value
|
initPayload[key] = registeredAtom.currentValue
|
||||||
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -215,7 +216,7 @@ local function syncAtomsToClient(client: any, atoms: { [string]: () -> any })
|
||||||
local syncedAtoms = clientRegister[client]
|
local syncedAtoms = clientRegister[client]
|
||||||
|
|
||||||
for key, atom in atoms do
|
for key, atom in atoms do
|
||||||
registerAtom(key, atom)
|
syncGlobalAtom(key, atom)
|
||||||
|
|
||||||
if not syncedAtoms[key] then
|
if not syncedAtoms[key] then
|
||||||
-- Mark the atom as pending initial state so we send its full value
|
-- Mark the atom as pending initial state so we send its full value
|
||||||
|
|
@ -263,18 +264,18 @@ local function flush()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local patches = if config.preserveHistory then createHistoricalPatches() else createPatch()
|
local patches = if config.preserveHistory then createGlobalPatchHistory() else createGlobalPatch()
|
||||||
|
|
||||||
-- While payload objects are technically mutable as they are unique to
|
-- While payload objects are technically mutable as they are unique to
|
||||||
-- each client, the data inside them is not safe to mutate.
|
-- each client, the data inside them is not safe to mutate.
|
||||||
if Charm.flags.frozen then
|
if globals.frozen then
|
||||||
Charm.deepFreeze(patches)
|
Charm.deepFreeze(patches)
|
||||||
end
|
end
|
||||||
|
|
||||||
for client in clientRegister do
|
for client in clientRegister do
|
||||||
local payloads: { SyncPayload } = if config.preserveHistory
|
local payloads: { SyncPayload } = if config.preserveHistory
|
||||||
then createHistoricalPayloadsForClient(client, patches)
|
then createPayloadHistoryForClient(client, patches)
|
||||||
else createPayloadsForClient(client, patches)
|
else createPayloadForClient(client, patches)
|
||||||
|
|
||||||
if payloads[1] then
|
if payloads[1] then
|
||||||
if task then
|
if task then
|
||||||
|
|
@ -285,10 +286,43 @@ local function flush()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
unregisterUnusedAtoms()
|
unsyncStaleGlobalAtoms()
|
||||||
table.clear(globalUpdates)
|
table.clear(globalUpdates)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Starts automatically syncing registered atoms to clients. Patches will
|
||||||
|
--- be sent to clients whenever the atoms are updated.
|
||||||
|
---
|
||||||
|
--- @param onSync The function to call when patches can be sent to a client.
|
||||||
|
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||||
|
if connection then
|
||||||
|
connection:Disconnect()
|
||||||
|
end
|
||||||
|
|
||||||
|
onSyncCallback = onSync
|
||||||
|
|
||||||
|
if not game then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local timer = 0
|
||||||
|
|
||||||
|
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
|
||||||
|
if config.interval < 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
timer += deltaTime
|
||||||
|
|
||||||
|
if timer >= config.interval then
|
||||||
|
timer = 0
|
||||||
|
debug.profilebegin("CharmSync")
|
||||||
|
flush()
|
||||||
|
debug.profileend()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
--- Stops automatically syncing registered atoms to clients. Patches will
|
--- Stops automatically syncing registered atoms to clients. Patches will
|
||||||
--- no longer be sent to clients until `startSync` is called again.
|
--- no longer be sent to clients until `startSync` is called again.
|
||||||
local function stopSync()
|
local function stopSync()
|
||||||
|
|
@ -307,42 +341,6 @@ local function reset()
|
||||||
stopSync()
|
stopSync()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Starts automatically syncing registered atoms to clients. Patches will
|
|
||||||
--- be sent to clients whenever the atoms are updated.
|
|
||||||
---
|
|
||||||
--- @param onSync The function to call when patches can be sent to a client.
|
|
||||||
--- @return `stopSync()`
|
|
||||||
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()): () -> ()
|
|
||||||
if connection then
|
|
||||||
connection:Disconnect()
|
|
||||||
end
|
|
||||||
|
|
||||||
onSyncCallback = onSync
|
|
||||||
|
|
||||||
if not game then
|
|
||||||
return stopSync
|
|
||||||
end
|
|
||||||
|
|
||||||
local timer = 0
|
|
||||||
|
|
||||||
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
|
|
||||||
if config.interval < 0 then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
timer += deltaTime
|
|
||||||
|
|
||||||
if timer >= config.interval then
|
|
||||||
timer = 0
|
|
||||||
debug.profilebegin("CharmSync")
|
|
||||||
flush()
|
|
||||||
debug.profileend()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
return stopSync
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
syncAtomsToClient = syncAtomsToClient,
|
syncAtomsToClient = syncAtomsToClient,
|
||||||
unsyncClient = unsyncClient,
|
unsyncClient = unsyncClient,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
local core = require("../../core")
|
local core = require("../../core")
|
||||||
local suite = require("../../../../../test/suite")
|
local suite = require("../../../../../test/suite")
|
||||||
|
|
||||||
local atom = core.atom
|
local signal = core.signal
|
||||||
local effect = core.effect
|
local effect = core.effect
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should accept equality function", function()
|
test("should accept equality function", function()
|
||||||
local calls = {}
|
local calls = {}
|
||||||
local src = atom(0, function(current, incoming)
|
local src, setSrc = signal(0, function(current, incoming)
|
||||||
return current == incoming or incoming % 2 == 1
|
return current == incoming or incoming % 2 == 1
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -15,23 +15,23 @@ test("should accept equality function", function()
|
||||||
table.insert(calls, src())
|
table.insert(calls, src())
|
||||||
end)
|
end)
|
||||||
|
|
||||||
src(2)
|
setSrc(2)
|
||||||
src(3)
|
setSrc(3)
|
||||||
assert(src() == 2, "should skip odd value")
|
assert(src() == 2, "should skip odd value")
|
||||||
|
|
||||||
src(4)
|
setSrc(4)
|
||||||
src(5)
|
setSrc(5)
|
||||||
assert(src() == 4, "should skip odd value")
|
assert(src() == 4, "should skip odd value")
|
||||||
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should enforce immutability", function()
|
test("should enforce immutability", function()
|
||||||
local src = atom({ nested = {} })
|
local src, setSrc = signal({ nested = {} })
|
||||||
assert(not pcall(function()
|
assert(not pcall(function()
|
||||||
src().nested.a = 1
|
src().nested.a = 1
|
||||||
end), "should not allow mutation of initial value")
|
end), "should not allow mutation of initial value")
|
||||||
|
|
||||||
src({ nested = { a = 1 } })
|
setSrc({ nested = { a = 1 } })
|
||||||
assert(not pcall(function()
|
assert(not pcall(function()
|
||||||
src().nested.a = 2
|
src().nested.a = 2
|
||||||
end), "should not allow mutation of new value")
|
end), "should not allow mutation of new value")
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
suite.describe("core", function()
|
suite.describe("core", function()
|
||||||
suite.describe("atom", function()
|
|
||||||
require("./core/atom.spec")
|
|
||||||
end)
|
|
||||||
suite.describe("computed", function()
|
suite.describe("computed", function()
|
||||||
require("./core/computed.spec")
|
require("./core/computed.spec")
|
||||||
end)
|
end)
|
||||||
|
|
@ -16,6 +13,9 @@ suite.describe("core", function()
|
||||||
suite.describe("gc", function()
|
suite.describe("gc", function()
|
||||||
require("./core/gc.spec")
|
require("./core/gc.spec")
|
||||||
end)
|
end)
|
||||||
|
suite.describe("signal", function()
|
||||||
|
require("./core/signal.spec")
|
||||||
|
end)
|
||||||
suite.describe("topology", function()
|
suite.describe("topology", function()
|
||||||
require("./core/topology.spec")
|
require("./core/topology.spec")
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
local alien = require("./alien")
|
local alien = require("./alien")
|
||||||
local config = require("./flags")
|
local globals = require("./globals")
|
||||||
local utils = require("./utils")
|
local utils = require("./utils")
|
||||||
|
|
||||||
export type ReactiveFlags = alien.ReactiveFlags
|
export type ReactiveFlags = alien.ReactiveFlags
|
||||||
|
|
@ -74,7 +74,7 @@ end
|
||||||
local function runCleanups(effect: EffectScope)
|
local function runCleanups(effect: EffectScope)
|
||||||
for _, cleanup in effect.cleanups do
|
for _, cleanup in effect.cleanups do
|
||||||
local prevSub = setActiveSub(nil)
|
local prevSub = setActiveSub(nil)
|
||||||
local success, result: any = (if config.strict then noYield else pcall)(cleanup)
|
local success, result: any = (if globals.strict then noYield else pcall)(cleanup)
|
||||||
activeSub = prevSub
|
activeSub = prevSub
|
||||||
if not success then
|
if not success then
|
||||||
error(result)
|
error(result)
|
||||||
|
|
@ -87,18 +87,17 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags)
|
||||||
sub.flags = bit32.band(flags, bit32.bnot(PENDING))
|
sub.flags = bit32.band(flags, bit32.bnot(PENDING))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function run(e: Effect | EffectScope, flags: ReactiveFlags): ()
|
local function run(e: Effect, flags: ReactiveFlags): ()
|
||||||
if
|
if
|
||||||
bit32.btest(flags, DIRTY)
|
bit32.btest(flags, DIRTY)
|
||||||
or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags)))
|
or (bit32.btest(flags, PENDING) and (checkDirty(e.deps :: Link, e) or setNotPending(e, flags)))
|
||||||
then
|
then
|
||||||
runCleanups(e)
|
runCleanups(e)
|
||||||
local e = e :: Effect
|
|
||||||
cycle += 1
|
cycle += 1
|
||||||
e.depsTail = nil
|
e.depsTail = nil
|
||||||
e.flags = bit32.bor(WATCHING, RECURSED_CHECK)
|
e.flags = bit32.bor(WATCHING, RECURSED_CHECK)
|
||||||
local prevSub = setActiveSub(e)
|
local prevSub = setActiveSub(e)
|
||||||
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(e.fn, e.stop)
|
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn, e.stop)
|
||||||
activeSub = prevSub
|
activeSub = prevSub
|
||||||
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
|
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
purgeDeps(e)
|
purgeDeps(e)
|
||||||
|
|
@ -155,7 +154,7 @@ local function updateComputed(c: Computed): boolean
|
||||||
c.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
|
c.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
|
||||||
local prevSub = setActiveSub(c)
|
local prevSub = setActiveSub(c)
|
||||||
local oldValue = c.value
|
local oldValue = c.value
|
||||||
local success, result: unknown = (if config.strict then noYield else pcall)(c.getter, oldValue)
|
local success, result: unknown = (if globals.strict then noYield else pcall)(c.getter, oldValue)
|
||||||
activeSub = prevSub
|
activeSub = prevSub
|
||||||
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
|
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
|
||||||
purgeDeps(c)
|
purgeDeps(c)
|
||||||
|
|
@ -163,7 +162,7 @@ local function updateComputed(c: Computed): boolean
|
||||||
error(result)
|
error(result)
|
||||||
end
|
end
|
||||||
if oldValue ~= result then
|
if oldValue ~= result then
|
||||||
if config.frozen then
|
if globals.frozen then
|
||||||
deepFreeze(result)
|
deepFreeze(result)
|
||||||
end
|
end
|
||||||
c.value = result
|
c.value = result
|
||||||
|
|
@ -205,14 +204,15 @@ local function computedOper<T>(this: Computed<T>): () -> T
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function signalOper<T>(this: Signal<T>, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
|
local function signalOper<T>(
|
||||||
return function(...: ((T) -> T) | T): T
|
this: Signal<T>,
|
||||||
if select("#", ...) ~= 0 then
|
equals: ((current: T, incoming: T) -> boolean)?
|
||||||
local update = ...
|
): (() -> T, (((T) -> T) | T) -> T)
|
||||||
|
local function setter(update: ((T) -> T) | T): T
|
||||||
local value: T
|
local value: T
|
||||||
if type(update) == "function" then
|
if type(update) == "function" then
|
||||||
local prevSub = setActiveSub(nil)
|
local prevSub = setActiveSub(nil)
|
||||||
local success, result: T = (if config.strict then noYield else pcall)(update, this.currentValue)
|
local success, result: T = (if globals.strict then noYield else pcall)(update, this.currentValue)
|
||||||
activeSub = prevSub
|
activeSub = prevSub
|
||||||
if not success then
|
if not success then
|
||||||
error(result)
|
error(result)
|
||||||
|
|
@ -222,7 +222,7 @@ local function signalOper<T>(this: Signal<T>, equals: ((current: T, incoming: T)
|
||||||
value = update
|
value = update
|
||||||
end
|
end
|
||||||
if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then
|
if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then
|
||||||
if config.frozen then
|
if globals.frozen then
|
||||||
deepFreeze(value)
|
deepFreeze(value)
|
||||||
end
|
end
|
||||||
this.pendingValue = value
|
this.pendingValue = value
|
||||||
|
|
@ -237,7 +237,9 @@ local function signalOper<T>(this: Signal<T>, equals: ((current: T, incoming: T)
|
||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
return this.currentValue
|
return this.currentValue
|
||||||
else
|
end
|
||||||
|
|
||||||
|
local function getter(): T
|
||||||
if bit32.btest(this.flags, DIRTY) then
|
if bit32.btest(this.flags, DIRTY) then
|
||||||
if updateSignal(this) then
|
if updateSignal(this) then
|
||||||
local subs = this.subs
|
local subs = this.subs
|
||||||
|
|
@ -259,7 +261,8 @@ local function signalOper<T>(this: Signal<T>, equals: ((current: T, incoming: T)
|
||||||
end
|
end
|
||||||
return this.currentValue
|
return this.currentValue
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
return getter, setter
|
||||||
end
|
end
|
||||||
|
|
||||||
local function effectScopeOper(this: EffectScope): () -> ()
|
local function effectScopeOper(this: EffectScope): () -> ()
|
||||||
|
|
@ -279,15 +282,18 @@ local function effectOper(this: Effect): () -> ()
|
||||||
return effectScopeOper(this)
|
return effectScopeOper(this)
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Creates a reactive atom that acts as both a getter and setter. Calling the
|
--- Creates a reactive signal that stores a value and notifies subscribers when
|
||||||
--- atom with an argument sets its value, while calling it with no arguments
|
--- the value changes. The signal provides both a getter and a setter.
|
||||||
--- returns its current value.
|
|
||||||
---
|
---
|
||||||
--- @param value The initial value of the atom. If empty, the atom's value will be `undefined`.
|
--- @param initialValue The initial value of the signal.
|
||||||
--- @param equals A comparator function to determine if the atom's value has changed.
|
--- @param equals A comparator function to determine if the signal's value has changed.
|
||||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-options
|
--- @return A tuple containing the getter and setter functions for the signal.
|
||||||
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals
|
||||||
if config.frozen then
|
local function signal<T>(
|
||||||
|
initialValue: T,
|
||||||
|
equals: ((current: T, incoming: T) -> boolean)?
|
||||||
|
): (() -> T, (((T) -> T) | T) -> T)
|
||||||
|
if globals.frozen then
|
||||||
deepFreeze(initialValue)
|
deepFreeze(initialValue)
|
||||||
end
|
end
|
||||||
return signalOper({
|
return signalOper({
|
||||||
|
|
@ -297,6 +303,20 @@ local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> bo
|
||||||
}, equals)
|
}, equals)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Creates a reactive atom that acts as both a getter and setter. Calling the
|
||||||
|
--- atom with an argument sets its value, while calling it with no arguments
|
||||||
|
--- returns its current value.
|
||||||
|
---
|
||||||
|
--- @param value The initial value of the atom. If empty, the atom's value will be `undefined`.
|
||||||
|
--- @param equals A comparator function to determine if the atom's value has changed.
|
||||||
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals
|
||||||
|
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
|
||||||
|
local getter, setter = signal(initialValue, equals)
|
||||||
|
return function(...: ((T) -> T) | T): T
|
||||||
|
return if select("#", ...) == 0 then getter() else setter((...))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
--- Creates a new atom that is computed based on the values of other atoms.
|
--- Creates a new atom that is computed based on the values of other atoms.
|
||||||
--- The returned atom is read-only, and its value is automatically updated when
|
--- The returned atom is read-only, and its value is automatically updated when
|
||||||
--- any atoms accessed from within the getter function change.
|
--- any atoms accessed from within the getter function change.
|
||||||
|
|
@ -343,11 +363,11 @@ local function effect(fn: (stop: () -> ()) -> ()): () -> ()
|
||||||
}
|
}
|
||||||
e.stop = effectOper(e)
|
e.stop = effectOper(e)
|
||||||
local prevSub = setActiveSub(e)
|
local prevSub = setActiveSub(e)
|
||||||
if prevSub and (config.trackInnerEffects or prevSub.flags == NONE) then
|
if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then
|
||||||
link(e, prevSub, 0)
|
link(e, prevSub, 0)
|
||||||
end
|
end
|
||||||
local stopped = false
|
local stopped = false
|
||||||
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn, function()
|
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn, function()
|
||||||
stopped = true
|
stopped = true
|
||||||
end)
|
end)
|
||||||
activeSub = prevSub
|
activeSub = prevSub
|
||||||
|
|
@ -379,7 +399,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> ()
|
||||||
if prevSub and not detached then
|
if prevSub and not detached then
|
||||||
link(e, prevSub, 0)
|
link(e, prevSub, 0)
|
||||||
end
|
end
|
||||||
local success, result: (() -> ())? = (if config.strict then noYield else pcall)(fn)
|
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn)
|
||||||
activeSub = prevSub
|
activeSub = prevSub
|
||||||
if not success then
|
if not success then
|
||||||
error(result)
|
error(result)
|
||||||
|
|
@ -456,7 +476,7 @@ end
|
||||||
--- @return The return value of the callback.
|
--- @return The return value of the callback.
|
||||||
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||||
startBatch()
|
startBatch()
|
||||||
local results: { any } = { (if config.strict then noYield else pcall)(callback, ...) }
|
local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) }
|
||||||
endBatch()
|
endBatch()
|
||||||
if not results[1] then
|
if not results[1] then
|
||||||
error(results[2])
|
error(results[2])
|
||||||
|
|
@ -464,6 +484,59 @@ local function batched<Args..., Results...>(callback: (Args...) -> Results..., .
|
||||||
return unpack(results, 2)
|
return unpack(results, 2)
|
||||||
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.
|
||||||
|
---
|
||||||
|
--- @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 = computed(function()
|
||||||
|
return getter()
|
||||||
|
end)
|
||||||
|
local current: T
|
||||||
|
local mounted = true
|
||||||
|
return effect(function()
|
||||||
|
local previous = current
|
||||||
|
current = tracked()
|
||||||
|
if mounted then
|
||||||
|
mounted = false
|
||||||
|
else
|
||||||
|
peek(callback, current, previous)
|
||||||
|
end
|
||||||
|
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, the previous value, and a cleanup function as arguments.
|
||||||
|
---
|
||||||
|
--- @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?, dispose: () -> ()) -> ()): () -> ()
|
||||||
|
local tracked = computed(function()
|
||||||
|
return getter()
|
||||||
|
end)
|
||||||
|
local current: T
|
||||||
|
local disposed = false
|
||||||
|
local dispose = function()
|
||||||
|
disposed = true
|
||||||
|
end
|
||||||
|
dispose = effect(function()
|
||||||
|
local previous = current
|
||||||
|
current = tracked()
|
||||||
|
peek(callback, current, previous, dispose)
|
||||||
|
end)
|
||||||
|
if disposed then
|
||||||
|
dispose()
|
||||||
|
end
|
||||||
|
return dispose
|
||||||
|
end
|
||||||
|
|
||||||
local function notify(e: ReactiveNode)
|
local function notify(e: ReactiveNode)
|
||||||
local flags = e.flags
|
local flags = e.flags
|
||||||
if not bit32.btest(flags, QUEUED) then
|
if not bit32.btest(flags, QUEUED) then
|
||||||
|
|
@ -513,6 +586,7 @@ return {
|
||||||
startBatch = startBatch,
|
startBatch = startBatch,
|
||||||
endBatch = endBatch,
|
endBatch = endBatch,
|
||||||
atom = atom,
|
atom = atom,
|
||||||
|
signal = signal,
|
||||||
computed = computed,
|
computed = computed,
|
||||||
effect = effect,
|
effect = effect,
|
||||||
effectScope = effectScope,
|
effectScope = effectScope,
|
||||||
|
|
@ -520,4 +594,6 @@ return {
|
||||||
untracked = untracked,
|
untracked = untracked,
|
||||||
peek = peek,
|
peek = peek,
|
||||||
batched = batched,
|
batched = batched,
|
||||||
|
subscribe = subscribe,
|
||||||
|
listen = listen,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,18 @@ local function inlineTest(): string
|
||||||
end
|
end
|
||||||
|
|
||||||
local isO2 = inlineTest() ~= "inlineTest"
|
local isO2 = inlineTest() ~= "inlineTest"
|
||||||
|
local isDev = not isO2 or _G.__DEV__ == true
|
||||||
|
|
||||||
return {
|
return {
|
||||||
--- Enforces synchronous, non-yielding behavior in atoms and effects.
|
--- Enforces synchronous, non-yielding behavior in atoms 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.
|
||||||
--- Enabled if the optimization level is below 2, which is true in studio.
|
--- Enabled if the optimization level is below 2, which is true in studio.
|
||||||
strict = not isO2,
|
strict = isDev,
|
||||||
--- Enforces immutability of atoms by deep-freezing table values. Enabled
|
--- Enforces immutability of atoms by deep-freezing table values. Enabled
|
||||||
--- if the optimization level is below 2, which is true in studio.
|
--- if the optimization level is below 2, which is true in studio.
|
||||||
frozen = not isO2,
|
frozen = isDev,
|
||||||
--- Whether inner effects should clean up after themselves when their
|
--- Whether parent effects should track inner effects and clean them up
|
||||||
--- parent effect re-runs. This is usually desirable, but can be disabled
|
--- when the parent effect re-runs. This is usually desirable, but can be
|
||||||
--- to revert to the old behavior. Defaults to `true`.
|
--- disabled to revert to the old behavior. Defaults to `true`.
|
||||||
trackInnerEffects = true,
|
trackInnerEffects = true,
|
||||||
}
|
}
|
||||||
27
packages/charm/src/index.d.ts
vendored
27
packages/charm/src/index.d.ts
vendored
|
|
@ -7,7 +7,7 @@ type Cleanup = () => void;
|
||||||
* current value.
|
* current value.
|
||||||
*/
|
*/
|
||||||
export interface Atom<T> {
|
export interface Atom<T> {
|
||||||
(value: T | ((value: T) => T)): T;
|
(newValue: T | ((currentValue: T) => T)): T;
|
||||||
(): T;
|
(): T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ 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 flags: {
|
export const globals: {
|
||||||
/**
|
/**
|
||||||
* Enforces synchronous, non-yielding behavior in atoms and effects.
|
* Enforces synchronous, non-yielding behavior in atoms 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.
|
||||||
|
|
@ -31,13 +31,30 @@ export const flags: {
|
||||||
*/
|
*/
|
||||||
frozen: boolean;
|
frozen: boolean;
|
||||||
/**
|
/**
|
||||||
* Whether inner effects should clean up after themselves when their
|
* Whether parent effects should track inner effects and clean them up
|
||||||
* parent effect re-runs. This is usually desirable, but can be disabled
|
* when the parent effect re-runs. This is usually desirable, but can be
|
||||||
* to revert to the old behavior. Defaults to `true`.
|
* disabled to revert to the old behavior. Defaults to `true`.
|
||||||
*/
|
*/
|
||||||
trackInnerEffects: boolean;
|
trackInnerEffects: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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#signalstate-equals
|
||||||
|
*/
|
||||||
|
export function signal<T>(
|
||||||
|
value: T,
|
||||||
|
equals?: Equals<T>,
|
||||||
|
): LuaTuple<[() => T, (newValue: T | ((currentValue: T) => T)) => T]>;
|
||||||
|
export function signal<T>(): LuaTuple<
|
||||||
|
[() => T | undefined, (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined]
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a reactive atom that acts as both a getter and setter. Calling the
|
* Creates a reactive atom that acts as both a getter and setter. Calling the
|
||||||
* atom with an argument sets its value, while calling it with no arguments
|
* atom with an argument sets its value, while calling it with no arguments
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
local core = require("@self/core")
|
local core = require("@self/core")
|
||||||
local flags = require("@self/flags")
|
local globals = require("@self/globals")
|
||||||
local listen = require("@self/listen")
|
|
||||||
local mapped = require("@self/mapped")
|
local mapped = require("@self/mapped")
|
||||||
local observe = require("@self/observe")
|
local observe = require("@self/observe")
|
||||||
local subscribe = require("@self/subscribe")
|
|
||||||
local types = require("@self/types")
|
|
||||||
local utils = require("@self/utils")
|
local utils = require("@self/utils")
|
||||||
|
|
||||||
|
type Update<T> = ((previous: T) -> T) | T
|
||||||
|
|
||||||
--- A reactive atom that acts as both a getter and setter. Calling the atom with
|
--- A reactive atom that acts as both a getter and setter. Calling the atom with
|
||||||
--- an argument sets its value, while calling it with no arguments returns its
|
--- an argument sets its value, while calling it with no arguments returns its
|
||||||
--- current value.
|
--- current value.
|
||||||
export type Atom<T = any> = types.Atom<T>
|
export type Atom<T = any> = (...Update<T>) -> T
|
||||||
|
|
||||||
export type Selector<T = any> = types.Selector<T>
|
export type Selector<T = any> = () -> T
|
||||||
|
|
||||||
return {
|
return {
|
||||||
core = core,
|
core = core,
|
||||||
flags = flags,
|
globals = globals,
|
||||||
atom = core.atom,
|
atom = core.atom,
|
||||||
batched = core.batched,
|
batched = core.batched,
|
||||||
computed = core.computed,
|
computed = core.computed,
|
||||||
effect = core.effect,
|
effect = core.effect,
|
||||||
effectScope = core.effectScope,
|
effectScope = core.effectScope,
|
||||||
|
listen = core.listen,
|
||||||
onCleanup = core.onCleanup,
|
onCleanup = core.onCleanup,
|
||||||
peek = core.peek,
|
peek = core.peek,
|
||||||
|
signal = core.signal,
|
||||||
|
subscribe = core.subscribe,
|
||||||
untracked = core.untracked,
|
untracked = core.untracked,
|
||||||
listen = listen,
|
|
||||||
mapped = mapped,
|
mapped = mapped,
|
||||||
observe = observe,
|
observe = observe,
|
||||||
subscribe = subscribe,
|
|
||||||
deepFreeze = utils.deepFreeze,
|
deepFreeze = utils.deepFreeze,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
local core = require("./core")
|
|
||||||
|
|
||||||
local computed = core.computed
|
|
||||||
local effect = core.effect
|
|
||||||
local peek = core.peek
|
|
||||||
|
|
||||||
--- 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, the previous value, and a cleanup function as arguments.
|
|
||||||
---
|
|
||||||
--- @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?, dispose: () -> ()) -> ()): () -> ()
|
|
||||||
local tracked = computed(function()
|
|
||||||
return getter()
|
|
||||||
end)
|
|
||||||
|
|
||||||
local current: T
|
|
||||||
local disposed = false
|
|
||||||
|
|
||||||
local function dispose()
|
|
||||||
disposed = true
|
|
||||||
end
|
|
||||||
|
|
||||||
dispose = effect(function()
|
|
||||||
local previous = current
|
|
||||||
current = tracked()
|
|
||||||
peek(callback, current, previous, dispose)
|
|
||||||
end)
|
|
||||||
|
|
||||||
if disposed then
|
|
||||||
dispose()
|
|
||||||
end
|
|
||||||
|
|
||||||
return dispose
|
|
||||||
end
|
|
||||||
|
|
||||||
return listen
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
local core = require("./core")
|
local core = require("./core")
|
||||||
local flags = require("./flags")
|
local globals = require("./globals")
|
||||||
|
|
||||||
|
local batched = core.batched
|
||||||
local effect = core.effect
|
local effect = core.effect
|
||||||
local effectScope = core.effectScope
|
local effectScope = core.effectScope
|
||||||
local onCleanup = core.onCleanup
|
local onCleanup = core.onCleanup
|
||||||
local startBatch = core.startBatch
|
|
||||||
local endBatch = core.endBatch
|
|
||||||
|
|
||||||
--- Calls the observer function for each unique key in the table returned by the
|
--- Calls the observer function for each unique key in the table returned by the
|
||||||
--- getter. The observer is called once for each key, and again when a new key
|
--- getter. The observer is called once for each key, and again when a new key
|
||||||
|
|
@ -51,13 +50,11 @@ local function observe<K, V>(getter: () -> { [K]: V }, observer: (item: V, key:
|
||||||
local previousItems = currentItems
|
local previousItems = currentItems
|
||||||
currentItems = getter()
|
currentItems = getter()
|
||||||
if currentItems ~= previousItems then
|
if currentItems ~= previousItems then
|
||||||
startBatch()
|
batched(updateScopes, currentItems, previousItems)
|
||||||
updateScopes(currentItems, previousItems)
|
|
||||||
endBatch()
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if flags.trackInnerEffects then
|
if globals.trackInnerEffects then
|
||||||
onCleanup(disposeScopes, true)
|
onCleanup(disposeScopes, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
local core = require("./core")
|
|
||||||
|
|
||||||
local computed = core.computed
|
|
||||||
local effect = core.effect
|
|
||||||
local peek = core.peek
|
|
||||||
|
|
||||||
--- 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.
|
|
||||||
---
|
|
||||||
--- @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 = computed(function()
|
|
||||||
return getter()
|
|
||||||
end)
|
|
||||||
|
|
||||||
local current: T
|
|
||||||
local mounted = true
|
|
||||||
|
|
||||||
return effect(function()
|
|
||||||
local previous = current
|
|
||||||
current = tracked()
|
|
||||||
if mounted then
|
|
||||||
mounted = false
|
|
||||||
else
|
|
||||||
peek(callback, current, previous)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
return subscribe
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
type Update<T> = ((previous: T) -> T) | T
|
|
||||||
|
|
||||||
export type Atom<T> = (...Update<T>) -> T
|
|
||||||
|
|
||||||
export type Selector<T> = () -> T
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
@ -16,6 +16,7 @@ local function useAtom<T>(getter: () -> T, dependencies: { any }?): T
|
||||||
|
|
||||||
React.useLayoutEffect(function()
|
React.useLayoutEffect(function()
|
||||||
return Charm.effect(function()
|
return Charm.effect(function()
|
||||||
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
Charm.untracked(setState, getter())
|
Charm.untracked(setState, getter())
|
||||||
end)
|
end)
|
||||||
end, dependencies or {})
|
end, dependencies or {})
|
||||||
|
|
@ -39,6 +40,7 @@ local function useAtomBinding<T>(getter: () -> T, dependencies: { any }?)
|
||||||
|
|
||||||
React.useLayoutEffect(function()
|
React.useLayoutEffect(function()
|
||||||
return Charm.effect(function()
|
return Charm.effect(function()
|
||||||
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
Charm.untracked(setBinding, getter())
|
Charm.untracked(setBinding, getter())
|
||||||
end)
|
end)
|
||||||
end, dependencies or {})
|
end, dependencies or {})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ local function useAtom<State>(getter: () -> State): () -> State
|
||||||
local state = Vide.source(getter())
|
local state = Vide.source(getter())
|
||||||
|
|
||||||
Vide.cleanup(Charm.effect(function()
|
Vide.cleanup(Charm.effect(function()
|
||||||
|
-- Wrap in untracked to avoid tracking inner effect and atom calls
|
||||||
Charm.untracked(state :: (State) -> State, getter())
|
Charm.untracked(state :: (State) -> State, getter())
|
||||||
end))
|
end))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.flags.strict = true
|
Charm.globals.strict = true
|
||||||
Charm.flags.frozen = true
|
Charm.globals.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