feat: add signal api

This commit is contained in:
littensy 2025-10-31 00:36:55 -07:00
parent bf92413a97
commit 1e0b10aef6
21 changed files with 300 additions and 293 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
type Update<T> = ((previous: T) -> T) | T
export type Atom<T> = (...Update<T>) -> T
export type Selector<T> = () -> T
return {}

View file

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

View file

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

View file

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