From a44355b3b670735c12bcac8d9e4f7da0b18a2b97 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:26:21 -0800 Subject: [PATCH] refactor: improve source readability --- .lute/check.luau | 64 ++++- packages/charm/src/init.luau | 486 ++++++++++++++++++++++----------- packages/charm/src/system.luau | 30 +- rokit.toml | 2 +- 4 files changed, 389 insertions(+), 193 deletions(-) diff --git a/.lute/check.luau b/.lute/check.luau index 2eed288..de811b0 100644 --- a/.lute/check.luau +++ b/.lute/check.luau @@ -1,7 +1,47 @@ local fs = require("@std/fs") +local json = require("@std/json") local net = require("@lute/net") local process = require("@lute/process") +local CURRENT_FFLAGS = "https://clientsettingscdn.roblox.com/v1/settings/application?applicationName=PCStudioApp" +local FFLAG_KINDS = { "FFlag", "FInt" } + +local function analyze() + local args = { + "luau-lsp", + "analyze", + "--sourcemap=sourcemap.json", + "--ignore=**/node_modules/**", + "--base-luaurc=.luaurc", + "--definitions=roblox.d.luau", + "--no-flags-enabled", + } + + local response = net.request(CURRENT_FFLAGS, { method = "GET" }) + local data: any = json.deserialize(response.body) + + for name, value in data.applicationSettings do + for _, kind in FFLAG_KINDS do + if string.find(name, `^{kind}Luau`) then + table.insert(args, `--flag:{string.sub(name, #kind + 1)}={value}`) + end + end + end + + table.insert(args, "packages") + table.insert(args, "tests") + table.insert(args, "benches") + + local result = process.run(args) + local stderr = string.gsub(result.stderr, "Unknown FFlag[^\n]+\n", "") + + print(stderr) + + if result.exitcode == 1 then + process.exit(1) + end +end + process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" }) fs.writestringtofile( @@ -9,17 +49,19 @@ fs.writestringtofile( net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body ) -process.run({ - "luau-lsp", - "analyze", - "--sourcemap=sourcemap.json", - "--ignore=**/node_modules/**", - "--base-luaurc=.luaurc", - "--defs=roblox.d.luau", - "packages", - "tests", - "benches", -}, { stdio = "inherit" }) +analyze() + +-- process.run({ +-- "luau-lsp", +-- "analyze", +-- "--sourcemap=sourcemap.json", +-- "--ignore=**/node_modules/**", +-- "--base-luaurc=.luaurc", +-- "--defs=roblox.d.luau", +-- "packages", +-- "tests", +-- "benches", +-- }, { stdio = "inherit" }) fs.remove("roblox.d.luau") diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 886605d..a1058d0 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,10 +1,13 @@ +--[[ + Reactive system adapted from alien-signals at + https://github.com/stackblitz/alien-signals/blob/master/src/system.ts +]] + local system = require("@self/system") -export type ReactiveFlags = system.ReactiveFlags - -export type ReactiveNode = system.ReactiveNode - export type Link = system.Link +export type ReactiveFlags = system.ReactiveFlags +export type ReactiveNode = system.ReactiveNode export type Atom = (...((T) -> T) | T) -> T @@ -36,30 +39,34 @@ type SignalNode = ReactiveNode & { pendingValue: T, } -local NONE = 0b0000000 -local MUTABLE = 0b0000001 -local WATCHING = 0b0000010 -local RECURSED_CHECK = 0b0000100 -local DIRTY = 0b0010000 -local PENDING = 0b0100000 -local PEEKING = 0b1000000 +-- Bitmask flags for tracking the state of reactive nodes. Flags are combined +-- and checked using bitwise operations for efficiency. +local NONE = 0b0000000 -- Default state +local MUTABLE = 0b0000001 -- Can be changed (signal or computed) +local WATCHING = 0b0000010 -- Watching for changes (effects) +local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies +local DIRTY = 0b0010000 -- Value has changed and needs updating +local PENDING = 0b0100000 -- Might be dirty, pending verification +local PEEKING = 0b1000000 -- Blocking dependency tracking local function inlineTest(): string return debug.info(1, "n") end +local IS_O2 = inlineTest() ~= "inlineTest" + local flags = { --[[ Enforces synchronous, non-yielding behavior in signals and effects. Also enables state validation in Charm Sync to catch sync errors early. Enabled if the optimization level is below 2, which is true in studio. ]] - strict = inlineTest() == "inlineTest", + strict = not IS_O2, --[[ Enforces immutability of tables by deep-freezing table values. Enabled if the optimization level is below 2, which is true in studio. ]] - frozen = inlineTest() == "inlineTest", + frozen = not IS_O2, --[[ Whether parent effects should track inner effects and clean them up when the parent effect re-runs. This is usually desirable, but can be @@ -68,27 +75,45 @@ local flags = { trackInnerEffects = true, } +local ReactiveProxy = { + proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), +} + +-- Alien Signals reactive system local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () local unlink: (link: Link, sub: ReactiveNode?) -> Link? local propagate: (link: Link) -> () local checkDirty: (link: Link, sub: ReactiveNode) -> boolean local shallowPropagate: (link: Link) -> () -local ReactiveProxy = { - proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), -} - +-- Global version counter for dependency link deduplication. Incremented at the +-- start of each effect/computed run to ensure links created in the same cycle +-- are deduplicated. local cycle = 0 -local batchDepth = 0 -local notifyIndex = 0 +-- Effects runs are queued to prevent redundant executions when multiple +-- signals update in the same batch. local queued: { EffectNode? } = {} local queuedLength = 0 +local notifyIndex = 0 +-- Batch operations group multiple updates together, deferring effect runs +-- until the batch completes. Effects only run when the outermost batch ends. +local batchDepth = 0 +-- Tracks the currently active effect or computed signal during execution. +-- Dependencies accessed during this time will link to the active subscriber. local activeSub: ReactiveNode? +--[[ + Wraps a function to prevent yielding when in strict mode. If the function + attempts to yield, an error is thrown. + + @param fn The function to wrap. + @return A wrapped function that cannot yield in strict mode. +]] local function noYield(fn: (Args...) -> Results...): (Args...) -> Results... if not flags.strict then return fn end + local function resultHandler(thread: thread, ok: boolean, ...) if not ok then error(debug.traceback(thread, tostring((...)))) @@ -97,31 +122,57 @@ local function noYield(fn: (Args...) -> Results...): (Args. end return ... end + return function(...) local thread = coroutine.create(fn) return resultHandler(thread, coroutine.resume(thread, ...)) end end -local function deepFreeze(object: any) - if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then - table.freeze(object) - for _, value in object do - deepFreeze(value) +--[[ + Recursively freezes a table and all nested tables to enforce immutability. + Tables with metatables are skipped, as they are assumed to be mutable. + + @param value Table to freeze +]] +local function deepFreeze(value: any) + if type(value) == "table" and not table.isfrozen(value) and getmetatable(value) == nil then + table.freeze(value) + for _, entry in value do + deepFreeze(entry) end end end +--[[ + Returns the currently active subscriber (effect or computed node). + + @return Current active subscriber or `nil` +]] local function getActiveSub(): ReactiveNode? return activeSub end +--[[ + Sets the currently active subscriber (effect or computed node) and returns + the previous subscriber. + + @param sub New subscriber to set as active + @return Previous active subscriber +]] local function setActiveSub(sub: ReactiveNode?) local prevSub = activeSub activeSub = sub return prevSub end +--[[ + Removes dependencies from the subscriber's dependency list. When called + after tracking is complete, this ensures that only currently tracked + dependencies remain linked. + + @param sub Effect or computed node +]] local function purgeDeps(sub: ReactiveNode) local depsTail = sub.depsTail local dep = if depsTail then depsTail.nextDep else sub.deps @@ -130,41 +181,58 @@ local function purgeDeps(sub: ReactiveNode) end end -local function runCleanups(effect: EffectScopeNode) - local cleanups = effect.cleanups +--[[ + Runs all cleanup functions registered with the effect node. Any errors + thrown during cleanup are caught and logged as warnings. + + @param cleanups List of cleanup functions +]] +local function runCleanups(cleanups: { () -> () }) for key, cleanup in cleanups do - local prevSub = setActiveSub(nil) - local success, result: any = pcall(cleanup) - activeSub = prevSub cleanups[key] = nil - if not success then - warn("An error occurred during effect cleanup:", result) - end + local prevSub = setActiveSub(nil) + xpcall(cleanup, warn or print) + activeSub = prevSub end end -local function run(e: EffectNode) - local flags = e.flags - if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then - runCleanups(e) +--[[ + Checks if the effect node needs to run based on its flags and dependencies. + Effects run if they are marked dirty or pending with dirty dependencies. + + @param effect Effect node to run +]] +local function run(effect: EffectNode) + local flags = effect.flags + + if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(effect.deps :: Link, effect)) then + runCleanups(effect.cleanups) + cycle += 1 - e.depsTail = nil - e.flags = bit32.bor(WATCHING, RECURSED_CHECK) - local prevSub = setActiveSub(e) - local success, result = pcall(e.fn) + effect.depsTail = nil + effect.flags = bit32.bor(WATCHING, RECURSED_CHECK) + + local prevSub = setActiveSub(effect) + local success, result = pcall(effect.fn) activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(e) + + effect.flags = bit32.band(effect.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(effect) + if not success then error(result) elseif result then - table.insert(e.cleanups, noYield(result)) + table.insert(effect.cleanups, noYield(result)) end else - e.flags = WATCHING + effect.flags = WATCHING end end +--[[ + Processes the queued effect runs. Effects are run in the order they were + queued, ensuring that all updates are processed. +]] local function flush() while notifyIndex < queuedLength do local effect = queued[notifyIndex] :: EffectNode @@ -172,14 +240,23 @@ local function flush() notifyIndex += 1 run(effect) end + notifyIndex = 0 queuedLength = 0 end +--[[ + Starts a batch operation, incrementing the batch depth counter. Effects + will not run until the outermost batch ends. +]] local function startBatch() batchDepth += 1 end +--[[ + Ends a batch operation, decrementing the batch depth counter. If this is + the outermost batch, queued effects are flushed and run. +]] local function endBatch() batchDepth -= 1 if batchDepth == 0 then @@ -187,56 +264,88 @@ local function endBatch() end end -local function updateComputed(c: ComputedNode): boolean +--[[ + Updates a computed signal by re-evaluating its getter function. Returns + whether the computed value changed. + + @param computed Computed node to update + @return Boolean indicating whether the computed value changed +]] +local function updateComputed(computed: ComputedNode): boolean cycle += 1 - c.depsTail = nil - c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) - local prevSub = setActiveSub(c) - local oldValue = c.value - local success, result = pcall(c.getter, oldValue) + computed.depsTail = nil + computed.flags = bit32.bor(MUTABLE, RECURSED_CHECK) + + local prevSub = setActiveSub(computed) + local oldValue = computed.value + local success, newValue = pcall(computed.getter, oldValue) activeSub = prevSub - c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK)) - purgeDeps(c) - if not success then - error(result) + + computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK)) + purgeDeps(computed) + + if success then + computed.value = newValue + return oldValue ~= newValue + else + error(newValue) end - if oldValue ~= result then - c.value = result +end + +--[[ + Updates a signal by applying its pending value. Returns whether the + signal's value changed. + + @param signal Signal node to update + @return Boolean indicating whether the signal's value changed +]] +local function updateSignal(signal: SignalNode): boolean + signal.flags = MUTABLE + local pendingValue = signal.pendingValue + if signal.currentValue ~= pendingValue then + signal.currentValue = pendingValue return true else return false end end -local function updateSignal(s: SignalNode): boolean - s.flags = MUTABLE - local pendingValue = s.pendingValue - if s.currentValue ~= pendingValue then - s.currentValue = pendingValue - return true - else - return false - end -end +--[[ + Clears the pending flag from a subscriber. Used inside of if conditions to + inline the operation. -local function setNotPending(sub: ReactiveNode, flags: ReactiveFlags) + @param sub Effect or computed node + @param flags Subscriber's current flags +]] +local function clearPending(sub: ReactiveNode, flags: ReactiveFlags) sub.flags = bit32.band(flags, bit32.bnot(PENDING)) end +--[[ + Returns a function that evaluates the computed value when accessed. + Computed values are lazily evaluated and cached unless marked dirty or + pending with dirty dependencies. + + @param this Computed node + @return Operator to read the computed value +]] local function computedOper(this: ComputedNode): () -> T local function readComputed(): T local flags = this.flags + if bit32.btest(flags, DIRTY) - or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or setNotPending(this, flags))) + or (bit32.btest(flags, PENDING) and (checkDirty(this.deps :: Link, this) or clearPending(this, flags))) then if updateComputed(this) then + -- Notify subscribers if the value changed local subs = this.subs if subs then shallowPropagate(subs) end end elseif flags == NONE then + -- First access should initialize the computed value this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) local prevSub = setActiveSub(this) local success, result = pcall(this.getter) @@ -248,31 +357,50 @@ local function computedOper(this: ComputedNode): () -> T error(result) end end + if activeSub then link(this, activeSub, cycle) end + return this.value :: T end + return readComputed end +--[[ + Returns functions to read and write a signal's value: + - Setter: Updates the signal's value and notifies subscribers + - Getter: Returns the current value and registers dependency + + @param this Signal node + @param equals Optional comparator function to check for value changes + @return Operator to get the signal value + @return Operator to set the signal value +]] local function signalOper( this: SignalNode, equals: ((current: T, incoming: T) -> boolean)? ): (() -> T, (((T) -> T) | T) -> T) local function writeSignal(update: ((T) -> T) | T): T local value: T + + -- Determine the new value based on whether the update is a function or + -- a direct value if type(update) == "function" then local prevSub = setActiveSub(nil) local success, result = pcall(noYield(update), this.pendingValue) activeSub = prevSub - if not success then + if success then + value = result + else error(result) end - value = result else value = update end + + -- Check if the new value is different from the pending value if if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then if flags.frozen then deepFreeze(value) @@ -288,10 +416,12 @@ local function signalOper( end return value end + return this.currentValue end local function readSignal(): T + -- Check if the signal needs to be updated (for signals within effects) if bit32.btest(this.flags, DIRTY) then if updateSignal(this) then local subs = this.subs @@ -300,6 +430,8 @@ local function signalOper( end end end + + -- Register this signal as a dependency of the active subscriber local sub = activeSub while sub do if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then @@ -309,12 +441,25 @@ local function signalOper( local subs = sub.subs sub = if subs then subs.sub else nil end + return this.currentValue end return readSignal, writeSignal end +--[[ + Returns an effect cleanup operator that disposes of the effect. This + operator performs a thorough cleanup: + + 1. Removes all dependency links + 2. Unlinks from parent effects/scopes + 3. Clears state flags to mark the effect as inactive + 4. Runs all registered cleanup functions + + @param this Effect node + @return Effect cleanup operator +]] local function effectOper(this: EffectScopeNode): () -> () return function() this.depsTail = nil @@ -324,10 +469,16 @@ local function effectOper(this: EffectScopeNode): () -> () if sub then unlink(sub, this) end - runCleanups(this) + runCleanups(this.cleanups) end end +--[[ + Updates a signal or computed value and returns whether the value changed. + + @param node Signal or computed node + @return Boolean indicating whether the value changed +]] local function update(node: ReactiveNode): boolean if node.depsTail then return updateComputed(node :: ComputedNode) @@ -336,10 +487,17 @@ local function update(node: ReactiveNode): boolean end end +--[[ + Queues an effect for execution, ensuring inner effects are notified in the + correct order. + + @param effect Effect node to notify +]] local function notify(effect: ReactiveNode) local insertIndex = queuedLength local firstInsertedIndex = insertIndex + -- Collect inner effects in a chain repeat effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) queued[insertIndex] = effect :: EffectNode @@ -351,8 +509,10 @@ local function notify(effect: ReactiveNode) until false queuedLength = insertIndex - insertIndex -= 1 + + -- Reverse the collected effects to maintain correct order + -- Note: insertIndex should decrement before the next condition evaluation while firstInsertedIndex < insertIndex do local left = queued[firstInsertedIndex] queued[firstInsertedIndex] = queued[insertIndex] @@ -362,6 +522,15 @@ local function notify(effect: ReactiveNode) end end +--[[ + Called when a node is not being watched by any subscribers. Cleans up the + node's dependencies and performs the necessary cleanup operations: + + - Computed signals: Unlink dependencies and mark as dirty for potential future updates + - Effects/scopes: Perform full cleanup to free resources + + @param node Reactive node (computed or effect/scope) +]] local function unwatched(node: ReactiveNode) if bit32.band(node.flags, MUTABLE) == 0 then effectOper(node :: EffectScopeNode)() @@ -376,10 +545,10 @@ end Creates a reactive signal that stores a value and notifies subscribers when the value changes. The signal provides both a getter and a setter. - @param initialValue The initial value of the signal. - @param equals A comparator function to determine if the signal's value has changed. - @return A function to get the current value of the signal. - @return A function to set a new value and notify subscribers. + @param initialValue Initial value for the signal + @param equals Optional comparator function to check for value changes + @return Getter function to read the signal value + @return Setter function to update the signal value @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals ]] local function signal( @@ -397,12 +566,12 @@ local function signal( end --[[ - Creates a reactive atom that acts as both a signal getter and setter. - Calling the atom with an argument sets its value, while calling it with no - arguments returns its current value. + Creates a signal and returns a function that can act as both the getter and + setter. Calling the atom with one argument sets its value, while calling it + with no arguments returns its current value. - @param initialValue The initial value of the atom. If empty, the atom's value will be `nil`. - @param equals A comparator function to determine if the atom's value has changed. + @param initialValue Initial value for the atom + @param equals Optional comparator function to check for value changes @see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-equals ]] local function atom(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom @@ -418,7 +587,8 @@ end other signals. The computed signal's value is automatically updated when a change is detected in any of its dependencies. - @param getter A function that produces the next value. + @param getter Function that calculates the next value + @return A function that returns the cached value @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter ]] local function computed(getter: (previousValue: T?) -> T): () -> T @@ -439,73 +609,88 @@ end If the effect is called within another effect, it will be disposed when the parent effect re-runs. - @param fn The function to run when dependencies change. - @return A function for disposing the effect. + @param fn Function to execute reactively + @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback ]] local function effect(fn: () -> ...(() -> ())?): () -> () - local e: EffectNode = { + local this: EffectNode = { fn = noYield(fn), flags = bit32.bor(WATCHING, RECURSED_CHECK), cleanups = {}, } - local prevSub = setActiveSub(e) - if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then - link(e, prevSub, 0) + + if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then + link(this, activeSub, 0) end - local success, result = pcall(e.fn) + + local prevSub = setActiveSub(this) + local success, result = pcall(this.fn) activeSub = prevSub - e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK)) + + this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK)) + if not success then error(result) elseif result then - table.insert(e.cleanups, noYield(result)) + table.insert(this.cleanups, noYield(result)) end - return effectOper(e) + + return effectOper(this) end --[[ Creates an effect scope that can capture reactive effects and computed signals created within it so that they can be disposed together. - @param fn A function that may create reactive effects. - @param detached If true, the scope will not be automatically disposed of when the parent scope is disposed. Defaults to false. - @return A function for disposing all effects created within the scope. + @param fn Function that creates effects within the scope + @param detached If true, the scope will not be tracked by the parent effect/scope + @return A cleanup function that stops all effects in the scope @see https://github.com/littensy/charm?tab=readme-ov-file#effectscopecallback-detached ]] local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () - local e: EffectScopeNode = { + local this: EffectScopeNode = { flags = NONE, cleanups = {}, } - local prevSub = setActiveSub(e) - if prevSub and not detached then - link(e, prevSub, 0) + + if activeSub and not detached then + link(this, activeSub, 0) end + + local prevSub = setActiveSub(this) local success, result = pcall(noYield(fn)) activeSub = prevSub + if not success then error(result) elseif result then - table.insert(e.cleanups, noYield(result)) + table.insert(this.cleanups, noYield(result)) end - return effectOper(e) + + return effectOper(this) end --[[ Allows you to manually trigger updates for downstream dependencies when you've directly mutated a signal's value without using the signal setter. - @param fn The dependency or a function that calls multiple dependencies. + @param fn A signal or function that accesses signals @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback ]] local function trigger(fn: () -> ()) local sub: ReactiveNode = { flags = WATCHING, } + local prevSub = setActiveSub(sub) local success, result: any = pcall(noYield(fn)) activeSub = prevSub + + if not success then + error(result) + end + local link = sub.deps while link do local dep = link.dep @@ -517,12 +702,10 @@ local function trigger(fn: () -> ()) shallowPropagate(subs) end end + if batchDepth == 0 then flush() end - if not success then - error(result) - end end --[[ @@ -530,8 +713,8 @@ end there is no active effect, a warning is logged, unless `failSilently` is set to `true`. - @param fn The cleanup function to register. - @param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. + @param fn Function to run during cleanup + @param failSilently If true, silences the warning when called outside an effect/scope @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently ]] local function onCleanup(fn: () -> (), failSilently: boolean?) @@ -548,9 +731,9 @@ end For an alternative that still cleans up inner effects, use `peek` instead. - @param fn A function that may read signals or create effects. - @param ...args Arguments to pass to the function. - @return The return value of the function. + @param fn A signal or function that accesses signals + @param ...args Arguments to pass to the function + @return Values returned by the function @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback ]] local function untracked(fn: (Args...) -> Results..., ...: Args...): Results... @@ -573,9 +756,9 @@ end To avoid capturing inner effects, use `untracked` instead. - @param fn A function that may read signals. - @param ...args Arguments to pass to the function. - @return The return value of the function. + @param fn A signal or function that accesses signals + @param ...args Arguments to pass to the function + @return Values returned by the function @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback ]] local function peek(fn: (Args...) -> Results..., ...: Args...): Results... @@ -600,9 +783,9 @@ end Combines multiple updates into a single "commit" that runs effects and computed signals after the provided callback finishes running. - @param fn A function that performs multiple updates. - @param ...args Arguments to pass to the function. - @return The return value of the function. + @param fn Function that updates signals + @param ...args Arguments to pass to the function + @return Values returned by the function @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback ]] local function batch(fn: (Args...) -> Results..., ...: Args...): Results... @@ -620,8 +803,8 @@ end signal, it is returned as-is. Useful for memoizing a getter function without creating unnecessary nested signals. - @param fn A function that returns the value to track. - @return A computed signal that tracks the value returned by the getter. + @param fn A signal or function that accesses signals + @return Computed signal if `fn` is not already a signal ]] local function toSignal(fn: () -> T): () -> T local name = debug.info(fn, "n") @@ -636,9 +819,9 @@ end Effects created within the callback are cleaned up when the listener runs or the listener is disposed. - @param getter A function that returns the value to watch. - @param fn The function to run when the value changes. - @return A function for disposing the effect. + @param getter A signal or function that accesses signals + @param fn Function to run when the value changes + @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback ]] local function listen(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () @@ -660,9 +843,9 @@ end Effects created within the callback are cleaned up when the listener runs or the listener is disposed. - @param getter A function that returns the value to watch. - @param fn The function to run when the value changes. - @return A function for disposing the effect. + @param getter A signal or function that accesses signals + @param fn Function to run when the value changes + @return A cleanup function that stops the effect @see https://github.com/littensy/charm?tab=readme-ov-file#subscribegetter-callback ]] local function subscribe(getter: () -> T, fn: (state: T, lastState: T) -> ()): () -> () @@ -710,9 +893,9 @@ end key is removed or the `observe()` effect is disposed. Effects created during the callback will also be bound to the lifetime of the key. - @param getter A function that returns the table to observe. - @param fn A function that is called for each key in the table. - @return A function for disposing the observer. + @param getter Function that returns a table + @param fn Function that is called for each key added to the table + @return A cleanup function that disposes scopes for all keys @see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback ]] local function observe(getter: () -> { [K]: V }, fn: (item: V, key: K) -> ...(() -> ())?): () -> () @@ -772,8 +955,8 @@ end of an existing atom. If the key is omitted from the mapper's return value, the original key is preserved. - @param getter Returns the table to map over. - @param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting table. + @param getter Function that returns a table + @param mapper Function that maps each entry from the source to a new entry in the mapped table @see https://github.com/littensy/charm?tab=readme-ov-file#mappedgetter-mapper ]] local function mapped(getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO)): () -> { [KO]: VO } @@ -834,8 +1017,6 @@ end --[[ Determines if the provided value is a reactive proxy created by `reactive()`. - @param value A value to test. - @return `true` if the value is a reactive proxy, `false` otherwise. @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function isReactive(value: any): boolean @@ -846,8 +1027,8 @@ end Unwraps a reactive proxy to get the original table. If the provided value is not a proxy, it is returned as-is. - @param value A reactive proxy or a non-proxy value. - @return The unwrapped target value. + @param value A reactive proxy or a non-proxy value + @return The source table if `value` is a reactive proxy @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function toRaw(value: T): T @@ -880,9 +1061,9 @@ end end) ``` - @param initialValue The value to make deeply reactive. - @return A proxy table that tracks reads to nested properties. - @return A function for updating the original table. + @param initialValue Source table to make deeply reactive + @return A proxy table that tracks reads to nested properties + @return A function for batching mutations to the source table @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue ]] local function reactive(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) @@ -926,9 +1107,9 @@ end Wraps a value in `reactive()` if it is a table; otherwise, returns the value as-is. - @param value A value to make reactive. - @param trackParent An optional tracking function to propagate deep updates. - @return A reactive proxy if the value is a table. + @param value Source value to make deeply reactive + @param trackParent Optional tracking function to propagate deep updates + @return A reactive proxy if the value is a table ]] local function toReactive(value: T, trackParent: (() -> ())?): T return if type(value) == "table" then reactive(value, trackParent) else value @@ -974,30 +1155,22 @@ function ReactiveProxy.__len(self: ReactiveProxy) return #self.__target end -local reactiveSystem = system.createReactiveSystem({ +link, unlink, propagate, checkDirty, shallowPropagate = system.createReactiveSystem({ update = update, notify = notify, unwatched = unwatched, }) -link = reactiveSystem.link -unlink = reactiveSystem.unlink -propagate = reactiveSystem.propagate -checkDirty = reactiveSystem.checkDirty -shallowPropagate = reactiveSystem.shallowPropagate - return { - ReactiveFlags = system.ReactiveFlags, - flags = flags, - getActiveSub = getActiveSub, - setActiveSub = setActiveSub, - startBatch = startBatch, - endBatch = endBatch, + -- Core reactive primitives signal = signal, computed = computed, effect = effect, effectScope = effectScope, trigger = trigger, + + -- Charm utilities + flags = flags, atom = atom, onCleanup = onCleanup, untracked = untracked, @@ -1011,7 +1184,16 @@ return { getter: () -> { [KI]: VI }, mapper: (item: VI, key: KI) -> (VO, KO) ) -> () -> { [KO]: VO }) & ((getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), + + -- Reactive proxies reactive = reactive, isReactive = isReactive, toRaw = toRaw, + + -- Advanced API + ReactiveFlags = system.ReactiveFlags, + getActiveSub = getActiveSub, + setActiveSub = setActiveSub, + startBatch = startBatch, + endBatch = endBatch, } diff --git a/packages/charm/src/system.luau b/packages/charm/src/system.luau index 565419e..547ac3d 100644 --- a/packages/charm/src/system.luau +++ b/packages/charm/src/system.luau @@ -1,28 +1,6 @@ --[[ Reactive system adapted from alien-signals at https://github.com/stackblitz/alien-signals/blob/master/src/system.ts - - MIT License - - Copyright (c) 2024-present Johnson Chu - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. ]] export type ReactiveNode = { @@ -249,13 +227,7 @@ local function createReactiveSystem(props: { return false end - return { - link = link, - unlink = unlink, - propagate = propagate, - checkDirty = checkDirty, - shallowPropagate = shallowPropagate, - } + return link, unlink, propagate, checkDirty, shallowPropagate end return { diff --git a/rokit.toml b/rokit.toml index d110a91..f31bd52 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,4 +4,4 @@ rojo = "rojo-rbx/rojo@7.6.1" stylua = "johnnymorganz/stylua@2.3.1" selene = "Kampfkarren/selene@0.29.0" luau-lsp = "johnnymorganz/luau-lsp@1.57.1" -lute = "luau-lang/lute@0.1.0-nightly.20251127" +lute = "luau-lang/lute@0.1.0-nightly.20251211"