refactor: remove unnecessary features, update docs

This commit is contained in:
littensy 2025-11-02 18:55:41 -08:00
parent 7a88c784fa
commit 37f79ecc3f
20 changed files with 774 additions and 955 deletions

996
README.md

File diff suppressed because it is too large Load diff

View file

@ -141,7 +141,7 @@ export namespace client {
*/ */
export namespace server { export namespace server {
/** /**
* Subscribes a client to the given atoms. When an update occurs, the client * Subscribes a client to the given signals. When an update occurs, the client
* will receive a partial state patch to merge into their local state. May be * will receive a partial state patch to merge into their local state. May be
* called multiple times to subscribe to additional keys. * called multiple times to subscribe to additional keys.
* *
@ -150,9 +150,9 @@ export namespace server {
* registering signals on the client should be done through `signalToAtom()`. * registering signals on the client should be done through `signalToAtom()`.
* *
* @param client The client receiving state updates. * @param client The client receiving state updates.
* @param atoms A map of atoms to sync with the client. * @param signals A map of signals to sync with the client.
*/ */
export function subscribeClient(client: Player, atoms: { [key: string]: Atom<any> }): void; export function subscribeClient(client: Player, signals: { [key: string]: () => any }): void;
/** /**
* Unsubscribes a client from receiving all state updates. To only unsubscribe * Unsubscribes a client from receiving all state updates. To only unsubscribe
@ -277,3 +277,14 @@ export function flattenAtoms<Atoms extends NestedAtomMap>(atoms: Atoms): Flatten
* @returns `true` if the value is `None`, otherwise `false`. * @returns `true` if the value is `None`, otherwise `false`.
*/ */
export function isNone(value: unknown): value is None; export function isNone(value: unknown): value is None;
/**
* Converts a signal getter and setter into an atom. This is useful for
* syncing client signals with the server, as the sync system requires both
* the getter and setter to update state.
*
* @param getter A function that returns the current value of the signal.
* @param setter A function that sets the value of the signal.
* @returns An atom that can either get or set the signal's value.
*/
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;

View file

@ -9,7 +9,7 @@ local globals = Charm.globals
type SyncStatus = number type SyncStatus = number
type ClientAtoms = { type ClientKeys = {
[string]: SyncStatus, [string]: SyncStatus,
} }
@ -29,17 +29,17 @@ type StateUpdate = {
local PENDING_INITIAL_STATE: SyncStatus = 0 local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1 local LISTENING_FOR_CHANGES: SyncStatus = 1
local trackedAtoms: { [string]: TrackedState } = {} local trackedSignals: { [string]: TrackedState } = {}
local globalUpdates: { StateUpdate } = {} local globalUpdates: { StateUpdate } = {}
local clients: { [any]: ClientAtoms } = {} local clients: { [any]: ClientKeys } = {}
local connection: RBXScriptConnection? local connection: RBXScriptConnection?
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()? local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
local function startTracking<T>(key: string, getter: () -> T) local function startTracking<T>(key: string, getter: () -> T)
if trackedAtoms[key] then if trackedSignals[key] then
-- Ignore duplicates, but warn against name collisions -- Ignore duplicates, but warn against name collisions
if trackedAtoms[key].getter ~= getter then if trackedSignals[key].getter ~= getter then
warn("[Charm] Attempted to sync different states with the same key:", key) warn("[Charm] Attempted to sync different states with the same key:", key)
end end
return return
@ -68,27 +68,27 @@ local function startTracking<T>(key: string, getter: () -> T)
trackedState.currentValue = newValue trackedState.currentValue = newValue
end) end)
trackedAtoms[key] = trackedState trackedSignals[key] = trackedState
end end
local function stopTracking(key: string) local function stopTracking(key: string)
local tracker = trackedAtoms[key] local tracker = trackedSignals[key]
if tracker then if tracker then
tracker.dispose() tracker.dispose()
trackedAtoms[key] = nil trackedSignals[key] = nil
end end
end end
local function purgeUntracked() local function purgeUntracked()
local active: { [string]: boolean } = {} local active: { [string]: boolean } = {}
for _, clientAtoms in clients do for _, clientKeys in clients do
for key in clientAtoms do for key in clientKeys do
active[key] = true active[key] = true
end end
end end
for key in trackedAtoms do for key in trackedSignals do
if not active[key] then if not active[key] then
stopTracking(key) stopTracking(key)
end end
@ -107,9 +107,9 @@ local function diffGlobalUpdateBuffer(): { any }
end end
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload } local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }
local clientAtoms = clients[client] local clientKeys = clients[client]
if not clientAtoms then if not clientKeys then
return {} return {}
end end
@ -125,7 +125,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
end end
local key = update.key local key = update.key
local status = clientAtoms[key] local status = clientKeys[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
table.insert(payloads, { table.insert(payloads, {
@ -137,20 +137,20 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
type = "init", type = "init",
data = { [key] = update.currentValue }, data = { [key] = update.currentValue },
}) })
clientAtoms[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
-- If a pending atom was never updated, we still need to send its initial -- If a pending signal was never updated, we still need to send its initial
-- state. This can happen if the atom was registered after its last change. -- state. This can happen if a signal was registered after its last change.
local initData: { [string]: any } = {} local initData: { [string]: any } = {}
for key, status in clientAtoms do for key, status in clientKeys do
local trackedState = trackedAtoms[key] local trackedState = trackedSignals[key]
if status == PENDING_INITIAL_STATE and trackedState then if status == PENDING_INITIAL_STATE and trackedState then
initData[key] = trackedState.currentValue initData[key] = trackedState.currentValue
clientAtoms[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
@ -165,7 +165,7 @@ local function diffGlobalState(): any
local currentGlobalState: { [string]: any } = {} local currentGlobalState: { [string]: any } = {}
local previousGlobalState: { [string]: any } = {} local previousGlobalState: { [string]: any } = {}
for key, tracker in trackedAtoms do for key, tracker in trackedSignals do
currentGlobalState[key] = tracker.currentValue currentGlobalState[key] = tracker.currentValue
previousGlobalState[key] = tracker.previousValue previousGlobalState[key] = tracker.previousValue
tracker.previousValue = tracker.currentValue tracker.previousValue = tracker.currentValue
@ -175,9 +175,9 @@ local function diffGlobalState(): any
end end
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload } local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }
local clientAtoms = clients[client] local clientKeys = clients[client]
if not clientAtoms then if not clientKeys then
return {} return {}
end end
@ -185,16 +185,16 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
local initData: { [string]: any } = {} local initData: { [string]: any } = {}
local patchData: { [string]: any } = {} local patchData: { [string]: any } = {}
-- Create patches for initialized atoms that changed, and add atoms that -- Create patches for initialized signals that changed, and add signals
-- are still pending initial state to the init payload. -- that are still pending initial state to the init payload.
for key, status in clientAtoms do for key, status in clientKeys do
local trackedState = trackedAtoms[key] local trackedState = trackedSignals[key]
if status == LISTENING_FOR_CHANGES then if status == LISTENING_FOR_CHANGES then
patchData[key] = globalPatch[key] patchData[key] = globalPatch[key]
elseif status == PENDING_INITIAL_STATE then elseif status == PENDING_INITIAL_STATE then
initData[key] = trackedState.currentValue initData[key] = trackedState.currentValue
clientAtoms[key] = LISTENING_FOR_CHANGES clientKeys[key] = LISTENING_FOR_CHANGES
end end
end end
@ -209,7 +209,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
return payloads return payloads
end end
--- Subscribes a client to the given atoms. When an update occurs, the client --- Subscribes a client to the given signals. When an update occurs, the client
--- will receive a partial state patch to merge into their local state. May be --- will receive a partial state patch to merge into their local state. May be
--- called multiple times to subscribe to additional keys. --- called multiple times to subscribe to additional keys.
--- ---
@ -218,21 +218,21 @@ end
--- registering signals on the client should be done through `signalToAtom()`. --- registering signals on the client should be done through `signalToAtom()`.
--- ---
--- @param client The client receiving state updates. --- @param client The client receiving state updates.
--- @param atoms A map of atoms to sync with the client. --- @param signals A map of signals to sync with the client.
local function subscribeClient(client: any, atoms: { [string]: () -> any }) local function subscribeClient(client: any, signals: { [string]: () -> any })
if not clients[client] then if not clients[client] then
clients[client] = {} clients[client] = {}
end end
local clientAtoms = clients[client] local clientKeys = clients[client]
for key, state in atoms do for key, state in signals do
startTracking(key, if type(state) == "function" then state else state.get) startTracking(key, if type(state) == "function" then state else state.get)
if not clientAtoms[key] then if not clientKeys[key] then
-- Mark the atom as pending initial state so we send its full value -- Mark the signal as pending initial state so we send its full
-- the next time we sync. -- value the next time we sync.
clientAtoms[key] = PENDING_INITIAL_STATE clientKeys[key] = PENDING_INITIAL_STATE
end end
end end
end end
@ -250,18 +250,18 @@ end
--- @param client The client receiving state updates. --- @param client The client receiving state updates.
--- @param keys The keys of the state to unsubscribe from. --- @param keys The keys of the state to unsubscribe from.
local function unsubscribeClientFrom(client: any, ...: string) local function unsubscribeClientFrom(client: any, ...: string)
local clientAtoms = clients[client] local clientKeys = clients[client]
if not clientAtoms then if not clientKeys then
return return
end end
for index = 1, select("#", ...) do for index = 1, select("#", ...) do
local key = select(index, ...) local key = select(index, ...)
clientAtoms[key] = nil clientKeys[key] = nil
end end
if next(clientAtoms) == nil then if next(clientKeys) == nil then
clients[client] = nil clients[client] = nil
end end
end end
@ -346,7 +346,7 @@ end
--- Unsubscribes all clients, stops tracking all state, and disconnects --- Unsubscribes all clients, stops tracking all state, and disconnects
--- from the automatic sync interval. --- from the automatic sync interval.
local function reset() local function reset()
table.clear(trackedAtoms) table.clear(trackedSignals)
table.clear(clients) table.clear(clients)
table.clear(globalUpdates) table.clear(globalUpdates)
disconnect() disconnect()

View file

@ -40,6 +40,13 @@ local function flattenState(states: NestedState): FlattenedState
return result return result
end end
--- Converts a signal getter and setter into an atom. This is useful for
--- syncing client signals with the server, as the sync system requires both
--- the getter and setter to update state.
---
--- @param getter A function that returns the current value of the signal.
--- @param setter A function that sets the value of the signal.
--- @return An atom that can either get or set the signal's value.
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T> local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
return function(...) return function(...)
return if select("#", ...) == 0 then getter() else setter((...)) return if select("#", ...) == 0 then getter() else setter((...))

View file

@ -354,25 +354,4 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 0, "should not run after b update") assert(calls == 0, "should not run after b update")
end) end)
test("should allow self cleanup", function()
local calls = 0
local cleanups = 0
local a, setA = signal(0)
effect(function(stopEffect)
onCleanup(function()
cleanups += 1
end)
stopEffect()
a()
calls += 1
end)
assert(calls == 1, "should call effect once")
assert(cleanups == 1, "should call cleanup once")
setA(1)
assert(calls == 1, "should not call effect after a update")
assert(cleanups == 1, "should not call cleanup after a update")
end)
return {} return {}

View file

@ -4,7 +4,6 @@ local suite = require("../../../../test/suite")
local signal = Charm.signal local signal = Charm.signal
local listen = Charm.listen local listen = Charm.listen
local effect = Charm.effect local effect = Charm.effect
local onCleanup = Charm.onCleanup
local test = suite.test local test = suite.test
test("should listen to selector", function() test("should listen to selector", function()
@ -116,24 +115,4 @@ test("should skip redundant updates", function()
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`) assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
end) end)
test("should allow self cleanup", function()
local calls = 0
local cleanups = 0
local a, setA = signal(0)
listen(a, function(_, _, stopEffect)
onCleanup(function()
cleanups += 1
end)
stopEffect()
calls += 1
end)
assert(calls == 1, "should call effect once")
assert(cleanups == 1, "should call cleanup once")
setA(1)
assert(calls == 1, "should not call effect after a update")
assert(cleanups == 1, "should not call cleanup after a update")
end)
return {} return {}

View file

@ -51,9 +51,8 @@ type EffectScope = ReactiveNode & {
} }
type Effect = ReactiveNode & { type Effect = ReactiveNode & {
fn: (stop: () -> ()) -> (() -> ())?, fn: () -> (() -> ())?,
cleanups: { () -> () }, cleanups: { () -> () },
stop: () -> (),
} }
type Computed<T = any> = ReactiveNode & { type Computed<T = any> = ReactiveNode & {
@ -117,7 +116,7 @@ local function run(e: Effect): ()
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 globals.strict then noYield else pcall)(e.fn, e.stop) local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn)
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)
@ -197,7 +196,7 @@ local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags)
end end
local function computedOper<T>(this: Computed<T>): () -> T local function computedOper<T>(this: Computed<T>): () -> T
return function(): T local function computedGetter(): T
local flags = this.flags local flags = this.flags
if if
bit32.btest(flags, DIRTY) bit32.btest(flags, DIRTY)
@ -225,13 +224,14 @@ local function computedOper<T>(this: Computed<T>): () -> T
end end
return this.value :: T return this.value :: T
end end
return computedGetter
end end
local function signalOper<T>( local function signalOper<T>(
this: Signal<T>, this: Signal<T>,
equals: ((current: T, incoming: T) -> boolean)? equals: ((current: T, incoming: T) -> boolean)?
): (() -> T, (((T) -> T) | T) -> T) ): (() -> T, (((T) -> T) | T) -> T)
local function setter(update: ((T) -> T) | T): T local function signalSetter(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)
@ -262,7 +262,7 @@ local function signalOper<T>(
return this.currentValue return this.currentValue
end end
local function getter(): T local function signalGetter(): 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
@ -285,7 +285,7 @@ local function signalOper<T>(
return this.currentValue return this.currentValue
end end
return getter, setter return signalGetter, signalSetter
end end
local function effectScopeOper(this: EffectScope): () -> () local function effectScopeOper(this: EffectScope): () -> ()
@ -311,7 +311,7 @@ end
--- @param initialValue The initial value of the signal. --- @param initialValue The initial value of the signal.
--- @param equals A comparator function to determine if the signal's value has changed. --- @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. --- @return A tuple containing the getter and setter functions for the signal.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals --- @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
local function signal<T>( local function signal<T>(
initialValue: T, initialValue: T,
equals: ((current: T, incoming: T) -> boolean)? equals: ((current: T, incoming: T) -> boolean)?
@ -326,30 +326,24 @@ local function signal<T>(
}, equals) }, equals)
end end
--- Creates a reactive atom that acts as both a getter and setter. Calling the --- Creates a reactive atom that acts as both a signal getter and setter.
--- atom with an argument sets its value, while calling it with no arguments --- Calling the atom with an argument sets its value, while calling it with no
--- returns its current value. --- arguments 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 atom. If empty, the atom's value will be `nil`.
--- @param equals A comparator function to determine if the atom's value has changed. --- @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 --- @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
local getter, setter = signal(initialValue, equals) local getter, setter = signal(initialValue, equals)
return function(...) local function atomGetterSetter(...)
return if select("#", ...) == 0 then getter() else setter((...)) return if select("#", ...) == 0 then getter() else setter((...))
end end
return atomGetterSetter
end end
--- Creates a new atom that is computed based on the values of other atoms. --- Creates a new read-only signal that is computed based on the values of
--- The returned atom is read-only, and its value is automatically updated when --- other signals. The computed signal's value is automatically updated when a
--- any atoms accessed from within the getter function change. --- change is detected in any of its dependencies.
---
--- @example
--- ```ts
--- const count = atom(1);
--- const plusOne = computed(() => count() + 1);
--- print(plusOne()); // 2
--- ```
--- ---
--- @param getter A function that produces the next value. --- @param getter A function that produces the next value.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter --- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
@ -360,50 +354,41 @@ local function computed<T>(getter: (previousValue: T?) -> T): () -> T
}) })
end end
--- Creates an effect that runs a callback in response to atom state changes. --- Creates an effect that runs a callback in response to signal state changes.
--- An effect tracks which atoms are accessed during its execution, and re-runs --- An effect tracks which signals are accessed during its execution, and runs
--- the callback when those atoms change. --- the callback when those signals change.
--- ---
--- The effect callback may return a cleanup function, which gets called once, --- The effect callback may return a cleanup function, which gets called once,
--- either before the effect re-runs or when it is disposed. --- either before the effect re-runs or when it is disposed.
--- ---
--- If the effect is called within another effect, it will be disposed when the --- If the effect is called within another effect, it will be disposed when the
--- outer effect re-runs. --- parent effect re-runs.
--- ---
--- @param fn The function to run when dependencies change. --- @param fn The function to run when dependencies change.
--- @return A function for disposing the effect. --- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback --- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
local function effect(fn: (stop: () -> ()) -> ()): () -> () local function effect(fn: () -> ()): () -> ()
local e: Effect = { local e: Effect = {
fn = fn, fn = fn,
flags = WATCHING, flags = WATCHING,
cleanups = {}, cleanups = {},
stop = nil :: any,
} }
e.stop = effectOper(e)
local prevSub = setActiveSub(e) local prevSub = setActiveSub(e)
if prevSub and (globals.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 success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn)
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn, function()
stopped = true
end)
activeSub = prevSub activeSub = prevSub
if not success then if not success then
error(result) error(result)
elseif result then elseif result then
table.insert(e.cleanups, result) table.insert(e.cleanups, result)
end end
if stopped then return effectOper(e)
e.stop()
end
return e.stop
end end
--- Creates an effect scope that can capture multiple reactive effects (i.e. --- Creates an effect scope that can capture reactive effects and computed
--- computed, effect, subscribe) created within it so that they can be disposed --- signals created within it so that they can be disposed together.
--- together.
--- ---
--- @param fn A function that may create reactive effects. --- @param fn A function that may create reactive effects.
--- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. --- @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
@ -428,12 +413,12 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> ()
return effectScopeOper(e) return effectScopeOper(e)
end end
--- Registers a cleanup function to the current active effect or effect scope. --- Binds the cleanup function to the current active effect or effect scope. If
--- If there is no active effect or scope, a warning is logged and the callback --- there is no active effect, a warning is logged, unless `failSilently` is
--- is not registered, unless `failSilently` is set to true. --- set to `true`.
--- ---
--- @param callback The cleanup function to register. --- @param callback The cleanup function to register.
--- @param failSilently If true, suppresses the warning when there is no active effect or scope. Defaults to false. --- @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently --- @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
local function onCleanup(cleanup: () -> (), failSilently: boolean?) local function onCleanup(cleanup: () -> (), failSilently: boolean?)
if activeSub and (activeSub :: Effect).cleanups then if activeSub and (activeSub :: Effect).cleanups then
@ -443,7 +428,7 @@ local function onCleanup(cleanup: () -> (), failSilently: boolean?)
end end
end end
--- Runs the callback without subscribing to atom updates or capturing inner --- Runs the callback without subscribing to signal updates or capturing inner
--- effects in the parent effect or scope. --- effects in the parent effect or scope.
--- ---
--- For an alternative that still cleans up inner effects, use `peek` instead. --- For an alternative that still cleans up inner effects, use `peek` instead.
@ -462,7 +447,7 @@ local function untracked<Args..., Results...>(callback: (Args...) -> Results...,
return unpack(results, 2) return unpack(results, 2)
end end
--- Runs the callback without subscribing to atom updates, but still captures --- Runs the callback without subscribing to signal updates, but still captures
--- inner effects so that they dispose with the parent effect. --- inner effects so that they dispose with the parent effect.
--- ---
--- To avoid capturing inner effects, use `untracked` instead. --- To avoid capturing inner effects, use `untracked` instead.
@ -489,10 +474,10 @@ local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...:
end end
end end
--- Combines multiple atom updates into a single "commit" that runs effects at --- Combines multiple updates into a single "commit" that runs effects and
--- the end of the provided callback. --- computed signals after the provided callback finishes running.
--- ---
--- @param callback A function that performs multiple atom updates. --- @param callback A function that performs multiple updates.
--- @param args Arguments to pass to the callback. --- @param args Arguments to pass to the callback.
--- @return The return value of the callback. --- @return The return value of the callback.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback --- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback
@ -506,18 +491,56 @@ local function batched<Args..., Results...>(callback: (Args...) -> Results..., .
return unpack(results, 2) return unpack(results, 2)
end end
--- Converts a non-signal into a computed signal. If the callback is already a
--- signal, it is returned as-is. Useful for memoizing a getter function
--- without creating unnecessary nested signals.
---
--- @param getter A function that returns the value to track.
--- @return A computed signal that tracks the value returned by the getter.
local function toComputed<T>(getter: () -> T): () -> T
local name = debug.info(getter, "n")
if name == "signalGetter" or name == "computedGetter" or name == "atomGetterSetter" then
return getter
end
return computed(function()
return getter()
end)
end
--- Creates an effect that runs the callback one immediately, and then again
--- when the value returned by the getter changes. The callback receives the new
--- value and the previous value as arguments.
---
--- Effects created within the callback are cleaned up when the listener runs
--- or the listener is disposed.
---
--- @param getter A function that returns the value to watch.
--- @param callback The function to run when the value changes.
--- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
local function listen<T>(getter: () -> T, callback: (state: T, previous: T?) -> ()): () -> ()
local tracked = toComputed(getter)
local current: T
return effect(function()
local previous = current
current = tracked()
peek(callback, current, previous)
end)
end
--- Creates an effect that only runs the callback when the value returned by the --- 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 --- getter changes. The callback receives the new value and the previous value
--- as arguments. --- as arguments.
--- ---
--- Effects created within the callback are cleaned up when the listener runs
--- or the listener is disposed.
---
--- @param getter A function that returns the value to watch. --- @param getter A function that returns the value to watch.
--- @param callback The function to run when the value changes. --- @param callback The function to run when the value changes.
--- @return A function for disposing the effect. --- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback --- @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback
local function subscribe<T>(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> () local function subscribe<T>(getter: () -> T, callback: (state: T, lastState: T) -> ()): () -> ()
local tracked = computed(function() local tracked = toComputed(getter)
return getter()
end)
local current: T local current: T
local mounted = true local mounted = true
return effect(function() return effect(function()
@ -531,34 +554,6 @@ local function subscribe<T>(getter: () -> T, callback: (state: T, lastState: T)
end) 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 reactiveSystem = createReactiveSystem({ local reactiveSystem = createReactiveSystem({
update = function(node) update = function(node)
if node.depsTail then if node.depsTail then

View file

@ -6,11 +6,11 @@ local isO2 = inlineTest() ~= "inlineTest"
local isDev = not isO2 or _G.__DEV__ == true 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 signals and effects.
--- Also enables state validation in Charm Sync to catch sync errors early. --- Also enables state validation in Charm Sync to catch sync errors early.
--- 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 = isDev, strict = isDev,
--- Enforces immutability of atoms by deep-freezing table values. Enabled --- Enforces immutability of tables 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 = isDev, frozen = isDev,
--- Whether parent effects should track inner effects and clean them up --- Whether parent effects should track inner effects and clean them up

View file

@ -20,13 +20,13 @@ export type Equals<T> = (current: T, incoming: T) => boolean;
*/ */
export const globals: { export const globals: {
/** /**
* Enforces synchronous, non-yielding behavior in atoms and effects. * Enforces synchronous, non-yielding behavior in signals and effects.
* Also enables state validation in Charm Sync to catch sync errors early. * Also enables state validation in Charm Sync to catch sync errors early.
* 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: boolean; strict: boolean;
/** /**
* Enforces immutability of atoms by deep-freezing table values. Enabled * Enforces immutability of tables 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: boolean; frozen: boolean;
@ -44,84 +44,138 @@ export const globals: {
* *
* @param initialValue The initial value of the signal. * @param initialValue The initial value of the signal.
* @param equals A comparator function to determine if the signal's value has changed. * @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. * @returns A tuple containing the getter and setter functions for the signal.
* @see https://github.com/littensy/charm?tab=readme-ov-file#signalstate-equals * @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
*/ */
export function signal<T>( export function signal<T>(
value: T, initialValue: T,
equals?: Equals<T>, equals?: Equals<T>,
): LuaTuple<[() => T, (newValue: T | ((currentValue: T) => T)) => T]>; ): LuaTuple<[getter: () => T, setter: (newValue: T | ((currentValue: T) => T)) => T]>;
export function signal<T>(): LuaTuple< export function signal<T>(): LuaTuple<
[() => T | undefined, (newValue: T | ((currentValue?: T) => T | undefined)) => T | undefined] [getter: () => T | undefined, setter: (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 signal getter and setter.
* atom with an argument sets its value, while calling it with no arguments * Calling the atom with an argument sets its value, while calling it with no
* returns its current value. * arguments 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 atom. If empty, the atom's value will be `nil`.
* @param equals A comparator function to determine if the atom's value has changed. * @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-options * @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals
*/ */
export function atom<T>(value: T, equals?: Equals<T>): Atom<T>; export function atom<T>(initialValue: T, equals?: Equals<T>): Atom<T>;
export function atom<T>(): Atom<T | undefined>; export function atom<T>(): Atom<T | undefined>;
/** /**
* Creates a new atom that is computed based on the values of other atoms. * Creates a new read-only signal that is computed based on the values of
* The returned atom is read-only, and its value is automatically updated when * other signals. The computed signal's value is automatically updated when a
* any atoms accessed from within the getter function change. * change is detected in any of its dependencies.
*
* If an initial value is provided, the getter will receive this value as its
* first argument on the initial run, instead of `undefined`.
*
* @example
* ```ts
* const count = atom(1);
* const plusOne = computed(() => count() + 1);
* print(plusOne()); // 2
* ```
* *
* @param getter A function that produces the next value. * @param getter A function that produces the next value.
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options * @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
*/ */
export function computed<T>(getter: (previousValue?: T) => T): () => T; export function computed<T>(getter: (previousValue?: T) => T): () => T;
/** /**
* Creates an effect that runs a callback in response to atom state changes. * Creates an effect that runs a callback in response to signal state changes.
* An effect tracks which atoms are accessed during its execution, and re-runs * An effect tracks which signals are accessed during its execution, and runs
* the callback when those atoms change. * the callback when those signals change.
* *
* The effect callback may return a cleanup function, which gets called once, * The effect callback may return a cleanup function, which gets called once,
* either before the effect re-runs or when it is disposed. * either before the effect re-runs or when it is disposed.
* *
* If the effect is called within another effect, it will be disposed when the * If the effect is called within another effect, it will be disposed when the
* outer effect re-runs. * parent effect re-runs.
* *
* @param callback The function to run when dependencies change. * @param fn The function to run when dependencies change.
* @returns A function for disposing the effect. * @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback * @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
*/ */
export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup; export function effect(callback: () => Cleanup | void): Cleanup;
/** /**
* Creates an effect scope that can capture multiple reactive effects (i.e. * Creates an effect scope that can capture reactive effects and computed
* computed, effect, subscribe) created within it so that they can be disposed * signals created within it so that they can be disposed together.
* together.
* *
* @param callback A function that may create reactive effects. * @param fn A function that may create reactive effects.
* @param detached If true, the scope will not be automatically disposed of * @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false.
* when the parent scope is disposed. Defaults to false.
* @returns A function for disposing all effects created within the scope. * @returns A function for disposing all effects created within the scope.
* @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached * @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached
*/ */
export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup; export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup;
/**
* Binds the cleanup function to the current active effect or effect scope. If
* there is no active effect, a warning is logged, unless `failSilently` is
* set to `true`.
*
* @param callback The cleanup function to register.
* @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`.
* @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
*/
export function onCleanup(callback: Cleanup, failSilently?: boolean): void;
/**
* Runs the callback without subscribing to signal updates or capturing inner
* effects in the parent effect or scope.
*
* For an alternative that still cleans up inner effects, use `peek` instead.
*
* @param callback The callback function.
* @param args Arguments to pass to the callback.
* @returns The return value of the callback.
* @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
*/
export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the callback without subscribing to signal updates, but still captures
* inner effects so that they dispose with the parent effect.
*
* To avoid capturing inner effects, use `untracked` instead.
*
* @param callback The callback function.
* @param args Arguments to pass to the callback.
* @returns The return value of the callback.
* @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
*/
export function peek<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Combines multiple updates into a single "commit" that runs effects and
* computed signals after the provided callback finishes running.
*
* @param callback A function that performs multiple updates.
* @param args Arguments to pass to the callback.
* @returns The return value of the callback.
* @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback
*/
export function batched<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Creates an effect that runs the callback one immediately, and then again
* when the value returned by the getter changes. The callback receives the new
* value and the previous value as arguments.
*
* Effects created within the callback are cleaned up when the listener runs
* or the listener is disposed.
*
* @param getter A function that returns the value to watch.
* @param callback The function to run when the value changes.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/
export function listen<T>(getter: () => T, callback: (value: T, previousValue: T | undefined) => void): Cleanup;
/** /**
* Creates an effect that only runs the callback when the value returned by the * Creates an effect that only runs the callback when the value returned by the
* getter changes. The callback receives the new value and the previous value * getter changes. The callback receives the new value and the previous value
* as arguments. * as arguments.
* *
* Effects created within the callback are cleaned up when the listener runs
* or the listener is disposed.
*
* @param getter A function that returns the value to watch. * @param getter A function that returns the value to watch.
* @param callback The function to run when the value changes. * @param callback The function to run when the value changes.
* @returns A function for disposing the effect. * @returns A function for disposing the effect.
@ -130,28 +184,12 @@ export function effectScope(callback: () => Cleanup | void, detached?: boolean):
export function subscribe<T>(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup; export function subscribe<T>(getter: () => T, callback: (value: T, previousValue: T) => void): Cleanup;
/** /**
* Creates an effect that runs the callback one immediately, and then again * Creates a new read-only signal that is computed by mapping over the entries
* when the value returned by the getter changes. The callback receives the new * of an existing atom. If the key is omitted from the mapper's return value,
* value, the previous value, and a cleanup function as arguments. * the original key is preserved.
* *
* @param getter A function that returns the value to watch. * @param getter Returns the table to map over.
* @param callback The function to run when the value changes. * @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
* @returns A function for disposing the effect.
* @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
*/
export function listen<T>(
getter: () => T,
callback: (value: T, previousValue: T | undefined, dispose: Cleanup) => void,
): Cleanup;
/**
* Creates a new atom that is computed by mapping over the entries of an
* existing atom. The returned atom is read-only, and its value is
* automatically updated when the source atom changes.
*
* @param getter A function that returns the table to map over.
* @param mapper A function that transforms each value-key pair from the source
* into a new value or value-key pair for the resulting table.
* @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper * @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
*/ */
export function mapped<VI, KI, VO, KO = KI>( export function mapped<VI, KI, VO, KO = KI>(
@ -170,70 +208,27 @@ export function mapped<VI, KI extends Key, VO, KO extends Key = KI>(
): () => { readonly [K in KO]: VO }; ): () => { readonly [K in KO]: VO };
/** /**
* Calls the observer function for each unique key in the table returned by the * Calls the callback 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 callback is called once for each key, and again when a new key
* is added. * is added.
* *
* The observer may return a cleanup function, which will be called when the * The callback may return a cleanup function, which will be called when the
* key is removed or when the entire observer is disposed. * key is removed or when the entire callback is disposed.
*
* Effects created within the callback are cleaned up when the item is removed
* from the table or when the callback is disposed.
* *
* @param getter A function that returns the table to observe. * @param getter A function that returns the table to observe.
* @param observer A function that is called for each key in the table. * @param callback A function that is called for each key in the table.
* @returns A function for disposing the observer. * @returns A function for disposing the observer.
* @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer * @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
*/ */
export function observe<V, K = number>( export function observe<V, K = number>(
getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[], getter: () => Map<K, V> | ReadonlyMap<K, V> | readonly V[],
observer: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): Cleanup;
// Overload for objects // Overload for objects
export function observe<V, K extends Key>( export function observe<V, K extends Key>(
getter: () => { readonly [K in Key]: V }, getter: () => { readonly [K in Key]: V },
observer: (value: V, key: K) => Cleanup | void, callback: (value: V, key: K) => Cleanup | void,
): Cleanup; ): Cleanup;
/**
* Combines multiple atom updates into a single "commit" that runs effects at
* the end of the provided callback.
*
* @param callback A function that performs multiple atom updates.
* @param args Arguments to pass to the callback.
* @returns The return value of the callback.
*/
export function batched<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the callback without subscribing to atom updates or capturing inner
* effects in the parent effect or scope.
*
* For an alternative that still cleans up inner effects, use `peek` instead.
*
* @param callback The callback function.
* @param args Arguments to pass to the callback.
* @returns The return value of the callback.
*/
export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Runs the callback without subscribing to atom updates, but still captures
* inner effects so that they dispose with the parent effect.
*
* To avoid capturing inner effects, use `untracked` instead.
*
* @param callback The callback function.
* @param args Arguments to pass to the callback.
* @returns The return value of the callback.
*/
export function peek<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
/**
* Registers a cleanup function to the current active effect or effect scope.
* If there is no active effect or scope, a warning is logged and the callback
* is not registered, unless `failSilently` is set to true.
*
* @param callback The cleanup function to register.
* @param failSilently If true, suppresses the warning when there is no active
* effect or scope. Defaults to false.
* @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
*/
export function onCleanup(callback: Cleanup, failSilently?: boolean): void;

View file

@ -8,7 +8,7 @@ type Mapped =
& (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO })
local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO }
local currentData: { [KI]: VI } = {} local currentInput: { [KI]: VI } = {}
local keyCache: { [KI]: KO } = {} local keyCache: { [KI]: KO } = {}
local function map(value: VI, key: KI): (VO, KO) local function map(value: VI, key: KI): (VO, KO)
@ -25,38 +25,38 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
return computed(function(previousOutput) return computed(function(previousOutput)
local currentOutput: { [KO]: VO } = previousOutput or {} local currentOutput: { [KO]: VO } = previousOutput or {}
local previousData = currentData local previousInput = currentInput
currentData = getter() currentInput = getter()
for key in previousData do for keyIn in previousInput do
if currentData[key] ~= nil then if currentInput[keyIn] ~= nil then
continue continue
end end
local cachedKey = keyCache[key] local prevKeyOut = keyCache[keyIn]
if cachedKey then if prevKeyOut then
if currentOutput == previousOutput then if currentOutput == previousOutput then
currentOutput = table.clone(currentOutput) currentOutput = table.clone(currentOutput)
end end
currentOutput[cachedKey] = nil currentOutput[prevKeyOut] = nil
keyCache[key] = nil keyCache[keyIn] = nil
end end
end end
for key, value in currentData do for keyIn, valueIn in currentInput do
if value == previousData[key] then if valueIn == previousInput[keyIn] then
continue continue
end end
local valueOut, keyOut = map(value, key) local valueOut, keyOut = map(valueIn, keyIn)
if currentOutput[keyOut] ~= valueOut then if currentOutput[keyOut] ~= valueOut then
if currentOutput == previousOutput then if currentOutput == previousOutput then
currentOutput = table.clone(currentOutput) currentOutput = table.clone(currentOutput)
end end
currentOutput[keyOut] = valueOut currentOutput[keyOut] = valueOut
keyCache[key] = keyOut keyCache[keyIn] = keyOut
end end
end end
@ -64,12 +64,12 @@ local function mapped<KI, VI, KO, VO>(getter: () -> { [KI]: VI }, mapper: (item:
end) end)
end end
--- Creates a new atom that is computed by mapping over the entries of an --- Creates a new read-only signal that is computed by mapping over the entries
--- existing atom. The returned atom is read-only, and its value is --- of an existing atom. If the key is omitted from the mapper's return value,
--- automatically updated when the source atom changes. --- the original key is preserved.
--- ---
--- @param getter A function that returns the table to map over. --- @param getter Returns the table to map over.
--- @param mapper A function that transforms each value-key pair from the source into a new value or value-key pair for the resulting table. --- @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper --- @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper
local mapped = mapped :: Mapped local mapped = mapped :: Mapped

View file

@ -6,18 +6,21 @@ local effect = core.effect
local effectScope = core.effectScope local effectScope = core.effectScope
local onCleanup = core.onCleanup local onCleanup = core.onCleanup
--- Calls the observer function for each unique key in the table returned by the --- Calls the callback 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 callback is called once for each key, and again when a new key
--- is added. --- is added.
--- ---
--- The observer may return a cleanup function, which will be called when the --- The callback may return a cleanup function, which will be called when the
--- key is removed or when the entire observer is disposed. --- key is removed or when the entire callback is disposed.
---
--- Effects created within the callback are cleaned up when the item is removed
--- from the table or when the callback is disposed.
--- ---
--- @param getter A function that returns the table to observe. --- @param getter A function that returns the table to observe.
--- @param observer A function that is called for each key in the table. --- @param callback A function that is called for each key in the table.
--- @return A function for disposing the observer. --- @return A function for disposing the observer.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-observer --- @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback
local function observe<K, V>(getter: () -> { [K]: V }, observer: (item: V, key: K) -> ...(() -> ())?): () -> () local function observe<K, V>(getter: () -> { [K]: V }, callback: (item: V, key: K) -> ...(() -> ())?): () -> ()
local scopes: { [K]: () -> () } = {} local scopes: { [K]: () -> () } = {}
local currentItems: { [K]: V } = {} local currentItems: { [K]: V } = {}
@ -33,7 +36,7 @@ local function observe<K, V>(getter: () -> { [K]: V }, observer: (item: V, key:
for key, value in currentItems do for key, value in currentItems do
if previousItems[key] == nil and scopes[key] == nil then if previousItems[key] == nil and scopes[key] == nil then
scopes[key] = effectScope(function() scopes[key] = effectScope(function()
return observer(value, key) return callback(value, key)
end, true) end, true)
end end
end end

View file

@ -1,11 +1,11 @@
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
suite.describe("useAtom", function() suite.describe("useSignalState", function()
require("./useAtom.spec") require("./useSignalState.spec")
end) end)
suite.describe("useAtomBinding", function() suite.describe("useSignalBinding", function()
require("./useAtomBinding.spec") require("./useSignalBinding.spec")
end) end)
return {} return {}

View file

@ -3,45 +3,43 @@ local ReactCharm = require("../")
local renderHook = require("./helpers/renderHook") local renderHook = require("./helpers/renderHook")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom
local useAtomBinding = ReactCharm.useAtomBinding
local test = suite.test local test = suite.test
test("tracks atom state", function() test("tracks signal state", function()
local state = atom(0) local getState, setState = Charm.signal(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtomBinding(state) return ReactCharm.useSignalBinding(getState)
end) end)
assert(hook.result:getValue() == 0, "should return 0") assert(hook.result:getValue() == 0, "should return 0")
state(1) setState(1)
assert(hook.result:getValue() == 1, "should return 1") assert(hook.result:getValue() == 1, "should return 1")
end) end)
test("accepts a selector", function() test("accepts a selector", function()
local state = atom(0) local getState, setState = Charm.signal(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtomBinding(function() return ReactCharm.useSignalBinding(function()
return 2 * state() return 2 * getState()
end) end)
end) end)
assert(hook.result:getValue() == 0, "should return 0") assert(hook.result:getValue() == 0, "should return 0")
state(1) setState(1)
assert(hook.result:getValue() == 2, "should return 2") assert(hook.result:getValue() == 2, "should return 2")
end) end)
test("tracks dependencies", function() test("tracks dependencies", function()
local state = atom(0) local getState, setState = Charm.signal(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtomBinding(state, { state }) return ReactCharm.useSignalBinding(getState, { getState })
end) end)
assert(hook.result:getValue() == 0, "should return 0") assert(hook.result:getValue() == 0, "should return 0")
state(1) setState(1)
assert(hook.result:getValue() == 1, "should return 1") assert(hook.result:getValue() == 1, "should return 1")
state = atom(0) getState, setState = Charm.signal(0)
hook.rerender() hook.rerender()
assert(hook.result:getValue() == 0, "should return 0") assert(hook.result:getValue() == 0, "should return 0")
state(1) setState(1)
assert(hook.result:getValue() == 1, "should return 1") assert(hook.result:getValue() == 1, "should return 1")
end) end)

View file

@ -3,45 +3,43 @@ local ReactCharm = require("../")
local renderHook = require("./helpers/renderHook") local renderHook = require("./helpers/renderHook")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom
local useAtom = ReactCharm.useAtom
local test = suite.test local test = suite.test
test("tracks atom state", function() test("tracks signal state", function()
local state = atom(0) local getState, setState = Charm.signal(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtom(state) return ReactCharm.useSignalState(getState)
end) end)
assert(hook.result == 0, "should return 0") assert(hook.result == 0, "should return 0")
state(1) setState(1)
assert(hook.result == 1, "should return 1") assert(hook.result == 1, "should return 1")
end) end)
test("accepts a selector", function() test("accepts a selector", function()
local state = atom(0) local getState, setState = Charm.signal(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtom(function() return ReactCharm.useSignalState(function()
return 2 * state() return 2 * getState()
end) end)
end) end)
assert(hook.result == 0, "should return 0") assert(hook.result == 0, "should return 0")
state(1) setState(1)
assert(hook.result == 2, "should return 2") assert(hook.result == 2, "should return 2")
end) end)
test("tracks dependencies", function() test("tracks dependencies", function()
local state = atom(0) local getState, setState = Charm.signal(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtom(state, { state }) return ReactCharm.useSignalState(getState, { getState })
end) end)
assert(hook.result == 0, "should return 0") assert(hook.result == 0, "should return 0")
state(1) setState(1)
assert(hook.result == 1, "should return 1") assert(hook.result == 1, "should return 1")
state = atom(0) getState, setState = Charm.signal(0)
hook.rerender() hook.rerender()
assert(hook.result == 0, "should return 0") assert(hook.result == 0, "should return 0")
state(1) setState(1)
assert(hook.result == 1, "should return 1") assert(hook.result == 1, "should return 1")
end) end)

View file

@ -1,29 +1,31 @@
import { Binding } from "@rbxts/react"; import { Binding } from "@rbxts/react";
/** /**
* A hook that subscribes to changes in the given atom or selector. The * Returns a React state that updates when the getter function updates.
* component is re-rendered with the new state after an update. * Re-renders the component when the value changes.
* *
* If the `dependencies` array is provided, a new effect is created for the * If the `dependencies` array is provided, a new effect is created for the
* current `callback` when the dependencies change. Otherwise, the effect is * current `getter` when the dependencies change. Otherwise, the effect is
* created only once, when the component is mounted. * created only once, when the component is mounted.
* *
* @param callback The atom or selector to watch. * @param getter The signal or selector to watch.
* @param dependencies An array of values used to memoize the callback. * @param dependencies An array of values used to memoize the getter.
* @returns The current state. * @return The current state.
*/ */
export function useAtom<T>(callback: () => T, dependencies?: any[]): T; export function useSignalState<T>(getter: () => T, dependencies?: any[]): T;
/** /**
* A hook that subscribes to changes in the given atom or selector. Returns * Returns a React binding that updates when the getter function updates.
* a binding that updates when the state changes.
* *
* If the `dependencies` array is provided, a new effect is created for the * If the `dependencies` array is provided, a new effect is created for the
* current `callback` when the dependencies change. Otherwise, the effect is * current `getter` when the dependencies change. Otherwise, the effect is
* created only once, when the component is mounted. * created only once, when the component is mounted.
* *
* @param callback The atom or selector to watch. * @param getter The atom or selector to watch.
* @param dependencies An array of values used to memoize the callback. * @param dependencies An array of values used to memoize the getter.
* @returns A binding that stores the current state. * @return A binding that stores the current state.
*/ */
export function useAtomBinding<T>(callback: () => T, dependencies?: any[]): Binding<T>; export function useSignalBinding<T>(getter: () => T, dependencies?: any[]): Binding<T>;
// Aliases
export { useSignalState as useAtom };

View file

@ -1,31 +1,30 @@
local Charm = require("./Charm") local Charm = require("./Charm")
local React = require("./React") local React = require("./React")
--- A hook that subscribes to changes in the given atom or selector. The --- Returns a React state that updates when the getter function updates.
--- component is re-rendered with the new state after an update. --- Re-renders the component when the value changes.
--- ---
--- If the `dependencies` array is provided, a new effect is created for the --- If the `dependencies` array is provided, a new effect is created for the
--- current `getter` when the dependencies change. Otherwise, the effect is --- current `getter` when the dependencies change. Otherwise, the effect is
--- created only once, when the component is mounted. --- created only once, when the component is mounted.
--- ---
--- @param getter The atom or selector to watch. --- @param getter The signal or selector to watch.
--- @param dependencies An array of values used to memoize the getter. --- @param dependencies An array of values used to memoize the getter.
--- @return The current state. --- @return The current state.
local function useAtom<T>(getter: () -> T, dependencies: { any }?): T local function useSignalState<T>(getter: () -> T, dependencies: { any }?): T
local state, setState = React.useState(getter) local value, setValue = React.useState(getter)
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 -- Wrap in untracked to avoid tracking inner effect and atom calls
Charm.untracked(setState, getter()) Charm.untracked(setValue, getter())
end) end)
end, dependencies or {}) end, dependencies or {})
return state return value
end end
--- A hook that subscribes to changes in the given atom or selector. Returns --- Returns a React binding that updates when the getter function updates.
--- a binding that updates when the state changes.
--- ---
--- If the `dependencies` array is provided, a new effect is created for the --- If the `dependencies` array is provided, a new effect is created for the
--- current `getter` when the dependencies change. Otherwise, the effect is --- current `getter` when the dependencies change. Otherwise, the effect is
@ -34,9 +33,9 @@ end
--- @param getter The atom or selector to watch. --- @param getter The atom or selector to watch.
--- @param dependencies An array of values used to memoize the getter. --- @param dependencies An array of values used to memoize the getter.
--- @return A binding that stores the current state. --- @return A binding that stores the current state.
local function useAtomBinding<T>(getter: () -> T, dependencies: { any }?) local function useSignalBinding<T>(getter: () -> T, dependencies: { any }?)
local initialState = React.useMemo(getter, {}) local initialValue = React.useMemo(getter, {})
local binding, setBinding = React.useBinding(initialState) local binding, setBinding = React.useBinding(initialValue)
React.useLayoutEffect(function() React.useLayoutEffect(function()
return Charm.effect(function() return Charm.effect(function()
@ -49,6 +48,7 @@ local function useAtomBinding<T>(getter: () -> T, dependencies: { any }?)
end end
return { return {
useAtom = useAtom, useSignalState = useSignalState,
useAtomBinding = useAtomBinding, useSignalBinding = useSignalBinding,
useAtom = useSignalState,
} }

View file

@ -1,7 +1,7 @@
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
suite.describe("useAtom", function() suite.describe("useSignalState", function()
require("./useAtom.spec") require("./useSignalState.spec")
end) end)
return {} return {}

View file

@ -5,55 +5,55 @@ local suite = require("../../../../test/suite")
local test = suite.test local test = suite.test
test("tracks atom state", function() test("tracks signal state", function()
local state = Charm.atom(0) local getState, setState = Charm.signal(0)
local source local source
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
source = VideCharm.useAtom(state) source = VideCharm.useSignalState(getState)
end) end)
assert(source() == 0, "should return 0") assert(source() == 0, "should return 0")
state(1) setState(1)
assert(source() == 1, "should return 1") assert(source() == 1, "should return 1")
cleanup() cleanup()
end) end)
test("accepts a selector", function() test("accepts a selector", function()
local state = Charm.atom(0) local getState, setState = Charm.signal(0)
local source local source
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
source = VideCharm.useAtom(function() source = VideCharm.useSignalState(function()
return 2 * state() return 2 * getState()
end) end)
end) end)
assert(source() == 0, "should return 0") assert(source() == 0, "should return 0")
state(1) setState(1)
assert(source() == 2, "should return 2") assert(source() == 2, "should return 2")
cleanup() cleanup()
end) end)
test("updates source in untracked", function() test("updates source in untracked", function()
local outerAtom = Charm.atom(nil :: any) local getOuter, setOuter = Charm.signal(nil :: any)
local innerAtom = Charm.atom(0) local getInner, setInner = Charm.signal(0)
local spyCalls = 0 local spyCalls = 0
local cleanup = Vide.root(function() local cleanup = Vide.root(function()
return Vide.show(VideCharm.useAtom(outerAtom), function() return Vide.show(VideCharm.useSignalState(getOuter), function()
-- Charm effects created in Vide scopes triggered by useAtom might -- Charm effects created in Vide scopes triggered by useSignalState might
-- incorrectly get treated as an inner effect in useAtom. This test -- incorrectly get treated as an inner effect in useSignalState. This test
-- ensures that doesn't happen. -- ensures that doesn't happen.
Vide.cleanup(Charm.effect(function() Vide.cleanup(Charm.effect(function()
innerAtom() getInner()
spyCalls += 1 spyCalls += 1
end)) end))
return nil return nil
end) end)
end) end)
assert(spyCalls == 0, "should not call spy initially") assert(spyCalls == 0, "should not call spy initially")
outerAtom(true) setOuter(true)
assert(spyCalls == 1, "should call spy when visible") assert(spyCalls == 1, "should call spy when visible")
innerAtom(1) setInner(1)
assert(spyCalls == 2, "spy should run when counter updates") assert(spyCalls == 2, "spy should run when counter updates")
outerAtom({}) -- set to a truthy value to trigger an outer effect re-run setOuter({}) -- set to a truthy value to trigger an outer effect re-run
innerAtom(2) setInner(2)
assert(spyCalls == 3, "spy should still run when counter updates") assert(spyCalls == 3, "spy should still run when counter updates")
cleanup() cleanup()
end) end)

View file

@ -1,8 +1,11 @@
/** /**
* Subscribes to the state of an atom or selector. Returns a source that * Returns a reactive source that subscribes to the current value of the given
* updates when the state changes. * signal or getter function.
* *
* @param callback The atom or selector to subscribe to. * @param getter The getter function to subscribe to.
* @returns The reactive source. * @return The reactive source.
*/ */
export function useAtom<T>(callback: () => T, dependencies?: any[]): () => T; export function useSignalState<T>(getter: () => T, dependencies?: any[]): () => T;
// Aliases
export { useSignalState as useAtom };

View file

@ -1,12 +1,12 @@
local Charm = require("./Charm") local Charm = require("./Charm")
local Vide = require("./Vide") local Vide = require("./Vide")
--- Returns a reactive source that updates when the given getter function's --- Returns a reactive source that subscribes to the current value of the given
--- value changes. --- signal or getter function.
--- ---
--- @param getter The atom or selector to subscribe to. --- @param getter The getter function to subscribe to.
--- @return The reactive source. --- @return The reactive source.
local function useAtom<State>(getter: () -> State): () -> State local function useSignalState<State>(getter: () -> State): () -> State
local state = Vide.source(getter()) local state = Vide.source(getter())
Vide.cleanup(Charm.effect(function() Vide.cleanup(Charm.effect(function()
@ -18,5 +18,6 @@ local function useAtom<State>(getter: () -> State): () -> State
end end
return { return {
useAtom = useAtom, useSignalState = useSignalState,
useAtom = useSignalState,
} }