refactor: improve source readability

This commit is contained in:
littensy 2025-12-11 09:26:21 -08:00
parent e08204a9d1
commit a44355b3b6
4 changed files with 389 additions and 193 deletions

View file

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

View file

@ -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 = any> = (...((T) -> T) | T) -> T
@ -36,30 +39,34 @@ type SignalNode<T = any> = 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<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results...
if not flags.strict then
return fn
end
local function resultHandler(thread: thread, ok: boolean, ...)
if not ok then
error(debug.traceback(thread, tostring((...))))
@ -97,31 +122,57 @@ local function noYield<Args..., Results...>(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<T>(this: ComputedNode<T>): () -> 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<T>(this: ComputedNode<T>): () -> 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<T>(
this: SignalNode<T>,
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<T>(
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<T>(
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<T>(
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<T>(
@ -397,12 +566,12 @@ local function signal<T>(
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<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): Atom<T>
@ -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<T>(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<Args..., Results...>(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<Args..., Results...>(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<Args..., Results...>(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<T>(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<T>(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<T>(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<K, V>(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<KI, VI, KO, VO>(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<T>(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<T>(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<T>(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 }) & (<K, VI, 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,
}

View file

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

View file

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