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 fs = require("@std/fs")
local json = require("@std/json")
local net = require("@lute/net") local net = require("@lute/net")
local process = require("@lute/process") 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" }) process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })
fs.writestringtofile( fs.writestringtofile(
@ -9,17 +49,19 @@ fs.writestringtofile(
net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body net.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
) )
process.run({ analyze()
"luau-lsp",
"analyze", -- process.run({
"--sourcemap=sourcemap.json", -- "luau-lsp",
"--ignore=**/node_modules/**", -- "analyze",
"--base-luaurc=.luaurc", -- "--sourcemap=sourcemap.json",
"--defs=roblox.d.luau", -- "--ignore=**/node_modules/**",
"packages", -- "--base-luaurc=.luaurc",
"tests", -- "--defs=roblox.d.luau",
"benches", -- "packages",
}, { stdio = "inherit" }) -- "tests",
-- "benches",
-- }, { stdio = "inherit" })
fs.remove("roblox.d.luau") 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") local system = require("@self/system")
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
export type Link = system.Link export type Link = system.Link
export type ReactiveFlags = system.ReactiveFlags
export type ReactiveNode = system.ReactiveNode
export type Atom<T = any> = (...((T) -> T) | T) -> T export type Atom<T = any> = (...((T) -> T) | T) -> T
@ -36,30 +39,34 @@ type SignalNode<T = any> = ReactiveNode & {
pendingValue: T, pendingValue: T,
} }
local NONE = 0b0000000 -- Bitmask flags for tracking the state of reactive nodes. Flags are combined
local MUTABLE = 0b0000001 -- and checked using bitwise operations for efficiency.
local WATCHING = 0b0000010 local NONE = 0b0000000 -- Default state
local RECURSED_CHECK = 0b0000100 local MUTABLE = 0b0000001 -- Can be changed (signal or computed)
local DIRTY = 0b0010000 local WATCHING = 0b0000010 -- Watching for changes (effects)
local PENDING = 0b0100000 local RECURSED_CHECK = 0b0000100 -- Being checked for circular dependencies
local PEEKING = 0b1000000 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 local function inlineTest(): string
return debug.info(1, "n") return debug.info(1, "n")
end end
local IS_O2 = inlineTest() ~= "inlineTest"
local flags = { local flags = {
--[[ --[[
Enforces synchronous, non-yielding behavior in signals 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 = inlineTest() == "inlineTest", strict = not IS_O2,
--[[ --[[
Enforces immutability of tables 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 = inlineTest() == "inlineTest", frozen = not IS_O2,
--[[ --[[
Whether parent effects should track inner effects and clean them up Whether parent effects should track inner effects and clean them up
when the parent effect re-runs. This is usually desirable, but can be when the parent effect re-runs. This is usually desirable, but can be
@ -68,27 +75,45 @@ local flags = {
trackInnerEffects = true, trackInnerEffects = true,
} }
local ReactiveProxy = {
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }),
}
-- Alien Signals reactive system
local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> () local link: (dep: ReactiveNode, sub: ReactiveNode, version: number) -> ()
local unlink: (link: Link, sub: ReactiveNode?) -> Link? local unlink: (link: Link, sub: ReactiveNode?) -> Link?
local propagate: (link: Link) -> () local propagate: (link: Link) -> ()
local checkDirty: (link: Link, sub: ReactiveNode) -> boolean local checkDirty: (link: Link, sub: ReactiveNode) -> boolean
local shallowPropagate: (link: Link) -> () local shallowPropagate: (link: Link) -> ()
local ReactiveProxy = { -- Global version counter for dependency link deduplication. Incremented at the
proxyMap = setmetatable({} :: { [any]: ReactiveProxy }, { __mode = "v" }), -- start of each effect/computed run to ensure links created in the same cycle
} -- are deduplicated.
local cycle = 0 local cycle = 0
local batchDepth = 0 -- Effects runs are queued to prevent redundant executions when multiple
local notifyIndex = 0 -- signals update in the same batch.
local queued: { EffectNode? } = {} local queued: { EffectNode? } = {}
local queuedLength = 0 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? 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... local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args...) -> Results...
if not flags.strict then if not flags.strict then
return fn return fn
end end
local function resultHandler(thread: thread, ok: boolean, ...) local function resultHandler(thread: thread, ok: boolean, ...)
if not ok then if not ok then
error(debug.traceback(thread, tostring((...)))) error(debug.traceback(thread, tostring((...))))
@ -97,31 +122,57 @@ local function noYield<Args..., Results...>(fn: (Args...) -> Results...): (Args.
end end
return ... return ...
end end
return function(...) return function(...)
local thread = coroutine.create(fn) local thread = coroutine.create(fn)
return resultHandler(thread, coroutine.resume(thread, ...)) return resultHandler(thread, coroutine.resume(thread, ...))
end end
end end
local function deepFreeze(object: any) --[[
if type(object) == "table" and not table.isfrozen(object) and getmetatable(object) == nil then Recursively freezes a table and all nested tables to enforce immutability.
table.freeze(object) Tables with metatables are skipped, as they are assumed to be mutable.
for _, value in object do
deepFreeze(value) @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 end
end end
--[[
Returns the currently active subscriber (effect or computed node).
@return Current active subscriber or `nil`
]]
local function getActiveSub(): ReactiveNode? local function getActiveSub(): ReactiveNode?
return activeSub return activeSub
end 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 function setActiveSub(sub: ReactiveNode?)
local prevSub = activeSub local prevSub = activeSub
activeSub = sub activeSub = sub
return prevSub return prevSub
end 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 function purgeDeps(sub: ReactiveNode)
local depsTail = sub.depsTail local depsTail = sub.depsTail
local dep = if depsTail then depsTail.nextDep else sub.deps local dep = if depsTail then depsTail.nextDep else sub.deps
@ -130,41 +181,58 @@ local function purgeDeps(sub: ReactiveNode)
end end
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 for key, cleanup in cleanups do
local prevSub = setActiveSub(nil)
local success, result: any = pcall(cleanup)
activeSub = prevSub
cleanups[key] = nil cleanups[key] = nil
if not success then local prevSub = setActiveSub(nil)
warn("An error occurred during effect cleanup:", result) xpcall(cleanup, warn or print)
end activeSub = prevSub
end end
end end
local function run(e: EffectNode) --[[
local flags = e.flags Checks if the effect node needs to run based on its flags and dependencies.
if bit32.btest(flags, DIRTY) or (bit32.btest(flags, PENDING) and checkDirty(e.deps :: Link, e)) then Effects run if they are marked dirty or pending with dirty dependencies.
runCleanups(e)
@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 cycle += 1
e.depsTail = nil effect.depsTail = nil
e.flags = bit32.bor(WATCHING, RECURSED_CHECK) effect.flags = bit32.bor(WATCHING, RECURSED_CHECK)
local prevSub = setActiveSub(e)
local success, result = pcall(e.fn) local prevSub = setActiveSub(effect)
local success, result = pcall(effect.fn)
activeSub = prevSub 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 if not success then
error(result) error(result)
elseif result then elseif result then
table.insert(e.cleanups, noYield(result)) table.insert(effect.cleanups, noYield(result))
end end
else else
e.flags = WATCHING effect.flags = WATCHING
end end
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() local function flush()
while notifyIndex < queuedLength do while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: EffectNode local effect = queued[notifyIndex] :: EffectNode
@ -172,14 +240,23 @@ local function flush()
notifyIndex += 1 notifyIndex += 1
run(effect) run(effect)
end end
notifyIndex = 0 notifyIndex = 0
queuedLength = 0 queuedLength = 0
end end
--[[
Starts a batch operation, incrementing the batch depth counter. Effects
will not run until the outermost batch ends.
]]
local function startBatch() local function startBatch()
batchDepth += 1 batchDepth += 1
end 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() local function endBatch()
batchDepth -= 1 batchDepth -= 1
if batchDepth == 0 then if batchDepth == 0 then
@ -187,56 +264,88 @@ local function endBatch()
end end
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 cycle += 1
c.depsTail = nil computed.depsTail = nil
c.flags = bit32.bor(MUTABLE, RECURSED_CHECK) computed.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(c)
local oldValue = c.value local prevSub = setActiveSub(computed)
local success, result = pcall(c.getter, oldValue) local oldValue = computed.value
local success, newValue = pcall(computed.getter, oldValue)
activeSub = prevSub activeSub = prevSub
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(c) computed.flags = bit32.band(computed.flags, bit32.bnot(RECURSED_CHECK))
if not success then purgeDeps(computed)
error(result)
if success then
computed.value = newValue
return oldValue ~= newValue
else
error(newValue)
end end
if oldValue ~= result then end
c.value = result
--[[
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 return true
else else
return false return false
end end
end end
local function updateSignal(s: SignalNode): boolean --[[
s.flags = MUTABLE Clears the pending flag from a subscriber. Used inside of if conditions to
local pendingValue = s.pendingValue inline the operation.
if s.currentValue ~= pendingValue then
s.currentValue = pendingValue
return true
else
return false
end
end
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)) sub.flags = bit32.band(flags, bit32.bnot(PENDING))
end 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 computedOper<T>(this: ComputedNode<T>): () -> T
local function readComputed(): T local function readComputed(): T
local flags = this.flags local flags = this.flags
if if
bit32.btest(flags, DIRTY) 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 then
if updateComputed(this) then if updateComputed(this) then
-- Notify subscribers if the value changed
local subs = this.subs local subs = this.subs
if subs then if subs then
shallowPropagate(subs) shallowPropagate(subs)
end end
end end
elseif flags == NONE then elseif flags == NONE then
-- First access should initialize the computed value
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK) this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(this) local prevSub = setActiveSub(this)
local success, result = pcall(this.getter) local success, result = pcall(this.getter)
@ -248,31 +357,50 @@ local function computedOper<T>(this: ComputedNode<T>): () -> T
error(result) error(result)
end end
end end
if activeSub then if activeSub then
link(this, activeSub, cycle) link(this, activeSub, cycle)
end end
return this.value :: T return this.value :: T
end end
return readComputed return readComputed
end 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>( local function signalOper<T>(
this: SignalNode<T>, this: SignalNode<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 writeSignal(update: ((T) -> T) | T): T local function writeSignal(update: ((T) -> T) | T): T
local value: 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 if type(update) == "function" then
local prevSub = setActiveSub(nil) local prevSub = setActiveSub(nil)
local success, result = pcall(noYield(update), this.pendingValue) local success, result = pcall(noYield(update), this.pendingValue)
activeSub = prevSub activeSub = prevSub
if not success then if success then
value = result
else
error(result) error(result)
end end
value = result
else else
value = update value = update
end 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 if equals then not equals(this.pendingValue, value) else this.pendingValue ~= value then
if flags.frozen then if flags.frozen then
deepFreeze(value) deepFreeze(value)
@ -288,10 +416,12 @@ local function signalOper<T>(
end end
return value return value
end end
return this.currentValue return this.currentValue
end end
local function readSignal(): T local function readSignal(): T
-- Check if the signal needs to be updated (for signals within effects)
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
@ -300,6 +430,8 @@ local function signalOper<T>(
end end
end end
end end
-- Register this signal as a dependency of the active subscriber
local sub = activeSub local sub = activeSub
while sub do while sub do
if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then if bit32.btest(sub.flags, bit32.bor(MUTABLE, WATCHING)) then
@ -309,12 +441,25 @@ local function signalOper<T>(
local subs = sub.subs local subs = sub.subs
sub = if subs then subs.sub else nil sub = if subs then subs.sub else nil
end end
return this.currentValue return this.currentValue
end end
return readSignal, writeSignal return readSignal, writeSignal
end 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): () -> () local function effectOper(this: EffectScopeNode): () -> ()
return function() return function()
this.depsTail = nil this.depsTail = nil
@ -324,10 +469,16 @@ local function effectOper(this: EffectScopeNode): () -> ()
if sub then if sub then
unlink(sub, this) unlink(sub, this)
end end
runCleanups(this) runCleanups(this.cleanups)
end end
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 local function update(node: ReactiveNode): boolean
if node.depsTail then if node.depsTail then
return updateComputed(node :: ComputedNode) return updateComputed(node :: ComputedNode)
@ -336,10 +487,17 @@ local function update(node: ReactiveNode): boolean
end end
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 function notify(effect: ReactiveNode)
local insertIndex = queuedLength local insertIndex = queuedLength
local firstInsertedIndex = insertIndex local firstInsertedIndex = insertIndex
-- Collect inner effects in a chain
repeat repeat
effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING)) effect.flags = bit32.band(effect.flags, bit32.bnot(WATCHING))
queued[insertIndex] = effect :: EffectNode queued[insertIndex] = effect :: EffectNode
@ -351,8 +509,10 @@ local function notify(effect: ReactiveNode)
until false until false
queuedLength = insertIndex queuedLength = insertIndex
insertIndex -= 1 insertIndex -= 1
-- Reverse the collected effects to maintain correct order
-- Note: insertIndex should decrement before the next condition evaluation
while firstInsertedIndex < insertIndex do while firstInsertedIndex < insertIndex do
local left = queued[firstInsertedIndex] local left = queued[firstInsertedIndex]
queued[firstInsertedIndex] = queued[insertIndex] queued[firstInsertedIndex] = queued[insertIndex]
@ -362,6 +522,15 @@ local function notify(effect: ReactiveNode)
end end
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) local function unwatched(node: ReactiveNode)
if bit32.band(node.flags, MUTABLE) == 0 then if bit32.band(node.flags, MUTABLE) == 0 then
effectOper(node :: EffectScopeNode)() effectOper(node :: EffectScopeNode)()
@ -376,10 +545,10 @@ end
Creates a reactive signal that stores a value and notifies subscribers when Creates a reactive signal that stores a value and notifies subscribers when
the value changes. The signal provides both a getter and a setter. the value changes. The signal provides both a getter and a setter.
@param initialValue The initial value of the signal. @param initialValue Initial value for the signal
@param equals A comparator function to determine if the signal's value has changed. @param equals Optional comparator function to check for value changes
@return A function to get the current value of the signal. @return Getter function to read the signal value
@return A function to set a new value and notify subscribers. @return Setter function to update the signal value
@see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals @see https://github.com/littensy/charm?tab=readme-ov-file#signalinitialValue-equals
]] ]]
local function signal<T>( local function signal<T>(
@ -397,12 +566,12 @@ local function signal<T>(
end end
--[[ --[[
Creates a reactive atom that acts as both a signal getter and setter. Creates a signal and returns a function that can act as both the getter and
Calling the atom with an argument sets its value, while calling it with no setter. Calling the atom with one argument sets its value, while calling it
arguments returns its current value. 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 initialValue Initial value for the atom
@param equals A comparator function to determine if the atom's value has changed. @param equals Optional comparator function to check for value changes
@see https://github.com/littensy/charm?tab=readme-ov-file#atominitialValue-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)?): Atom<T> 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 other signals. The computed signal's value is automatically updated when a
change is detected in any of its dependencies. 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 @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
]] ]]
local function computed<T>(getter: (previousValue: T?) -> T): () -> T 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 If the effect is called within another effect, it will be disposed when the
parent effect re-runs. parent effect re-runs.
@param fn The function to run when dependencies change. @param fn Function to execute reactively
@return A function for disposing the effect. @return A cleanup function that stops 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: () -> ...(() -> ())?): () -> () local function effect(fn: () -> ...(() -> ())?): () -> ()
local e: EffectNode = { local this: EffectNode = {
fn = noYield(fn), fn = noYield(fn),
flags = bit32.bor(WATCHING, RECURSED_CHECK), flags = bit32.bor(WATCHING, RECURSED_CHECK),
cleanups = {}, cleanups = {},
} }
local prevSub = setActiveSub(e)
if prevSub and (flags.trackInnerEffects or prevSub.flags == NONE) then if activeSub and (flags.trackInnerEffects or activeSub.flags == NONE) then
link(e, prevSub, 0) link(this, activeSub, 0)
end end
local success, result = pcall(e.fn)
local prevSub = setActiveSub(this)
local success, result = pcall(this.fn)
activeSub = prevSub 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 if not success then
error(result) error(result)
elseif result then elseif result then
table.insert(e.cleanups, noYield(result)) table.insert(this.cleanups, noYield(result))
end end
return effectOper(e)
return effectOper(this)
end end
--[[ --[[
Creates an effect scope that can capture reactive effects and computed Creates an effect scope that can capture reactive effects and computed
signals created within it so that they can be disposed together. signals created within it so that they can be disposed together.
@param fn A function that may create reactive effects. @param fn Function that creates effects within the scope
@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 tracked by the parent effect/scope
@return A function for disposing all effects created within the scope. @return A cleanup function that stops all effects in 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
]] ]]
local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> () local function effectScope(fn: () -> ...(() -> ())?, detached: boolean?): () -> ()
local e: EffectScopeNode = { local this: EffectScopeNode = {
flags = NONE, flags = NONE,
cleanups = {}, cleanups = {},
} }
local prevSub = setActiveSub(e)
if prevSub and not detached then if activeSub and not detached then
link(e, prevSub, 0) link(this, activeSub, 0)
end end
local prevSub = setActiveSub(this)
local success, result = pcall(noYield(fn)) local success, result = pcall(noYield(fn))
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, noYield(result)) table.insert(this.cleanups, noYield(result))
end end
return effectOper(e)
return effectOper(this)
end end
--[[ --[[
Allows you to manually trigger updates for downstream dependencies when Allows you to manually trigger updates for downstream dependencies when
you've directly mutated a signal's value without using the signal setter. 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 @see https://github.com/littensy/charm?tab=readme-ov-file#triggercallback
]] ]]
local function trigger(fn: () -> ()) local function trigger(fn: () -> ())
local sub: ReactiveNode = { local sub: ReactiveNode = {
flags = WATCHING, flags = WATCHING,
} }
local prevSub = setActiveSub(sub) local prevSub = setActiveSub(sub)
local success, result: any = pcall(noYield(fn)) local success, result: any = pcall(noYield(fn))
activeSub = prevSub activeSub = prevSub
if not success then
error(result)
end
local link = sub.deps local link = sub.deps
while link do while link do
local dep = link.dep local dep = link.dep
@ -517,12 +702,10 @@ local function trigger(fn: () -> ())
shallowPropagate(subs) shallowPropagate(subs)
end end
end end
if batchDepth == 0 then if batchDepth == 0 then
flush() flush()
end end
if not success then
error(result)
end
end end
--[[ --[[
@ -530,8 +713,8 @@ end
there is no active effect, a warning is logged, unless `failSilently` is there is no active effect, a warning is logged, unless `failSilently` is
set to `true`. set to `true`.
@param fn The cleanup function to register. @param fn Function to run during cleanup
@param failSilently If `true`, suppresses the warning when there is no active effect or scope. Defaults to `false`. @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 @see https://github.com/littensy/charm?tab=readme-ov-file#oncleanupcallback-failsilently
]] ]]
local function onCleanup(fn: () -> (), failSilently: boolean?) local function onCleanup(fn: () -> (), failSilently: boolean?)
@ -548,9 +731,9 @@ end
For an alternative that still cleans up inner effects, use `peek` instead. For an alternative that still cleans up inner effects, use `peek` instead.
@param fn A function that may read signals or create effects. @param fn A signal or function that accesses signals
@param ...args Arguments to pass to the function. @param ...args Arguments to pass to the function
@return The return value of the function. @return Values returned by the function
@see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
]] ]]
local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results... local function untracked<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results...
@ -573,9 +756,9 @@ end
To avoid capturing inner effects, use `untracked` instead. To avoid capturing inner effects, use `untracked` instead.
@param fn A function that may read signals. @param fn A signal or function that accesses signals
@param ...args Arguments to pass to the function. @param ...args Arguments to pass to the function
@return The return value of the function. @return Values returned by the function
@see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
]] ]]
local function peek<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results... 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 Combines multiple updates into a single "commit" that runs effects and
computed signals after the provided callback finishes running. computed signals after the provided callback finishes running.
@param fn A function that performs multiple updates. @param fn Function that updates signals
@param ...args Arguments to pass to the function. @param ...args Arguments to pass to the function
@return The return value of the function. @return Values returned by the function
@see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback @see https://github.com/littensy/charm?tab=readme-ov-file#batchcallback
]] ]]
local function batch<Args..., Results...>(fn: (Args...) -> Results..., ...: Args...): Results... 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 signal, it is returned as-is. Useful for memoizing a getter function
without creating unnecessary nested signals. without creating unnecessary nested signals.
@param fn A function that returns the value to track. @param fn A signal or function that accesses signals
@return A computed signal that tracks the value returned by the getter. @return Computed signal if `fn` is not already a signal
]] ]]
local function toSignal<T>(fn: () -> T): () -> T local function toSignal<T>(fn: () -> T): () -> T
local name = debug.info(fn, "n") local name = debug.info(fn, "n")
@ -636,9 +819,9 @@ end
Effects created within the callback are cleaned up when the listener runs Effects created within the callback are cleaned up when the listener runs
or the listener is disposed. or the listener is disposed.
@param getter A function that returns the value to watch. @param getter A signal or function that accesses signals
@param fn The function to run when the value changes. @param fn Function to run when the value changes
@return A function for disposing the effect. @return A cleanup function that stops the effect
@see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback @see https://github.com/littensy/charm?tab=readme-ov-file#listengetter-callback
]] ]]
local function listen<T>(getter: () -> T, fn: (state: T, previous: T?) -> ()): () -> () 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 Effects created within the callback are cleaned up when the listener runs
or the listener is disposed. or the listener is disposed.
@param getter A function that returns the value to watch. @param getter A signal or function that accesses signals
@param fn The function to run when the value changes. @param fn Function to run when the value changes
@return A function for disposing the effect. @return A cleanup function that stops 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, fn: (state: T, lastState: T) -> ()): () -> () 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 key is removed or the `observe()` effect is disposed. Effects created
during the callback will also be bound to the lifetime of the key. during the callback will also be bound to the lifetime of the key.
@param getter A function that returns the table to observe. @param getter Function that returns a table
@param fn A function that is called for each key in the table. @param fn Function that is called for each key added to the table
@return A function for disposing the observer. @return A cleanup function that disposes scopes for all keys
@see https://github.com/littensy/charm?tab=readme-ov-file#observegetter-callback @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) -> ...(() -> ())?): () -> () 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, of an existing atom. If the key is omitted from the mapper's return value,
the original key is preserved. the original key is preserved.
@param getter Returns the table to map over. @param getter Function that returns a table
@param mapper Transforms each value-key pair from the source into a new value or value-key pair for the resulting 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 @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 } 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()`. 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 @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]] ]]
local function isReactive(value: any): boolean local function isReactive(value: any): boolean
@ -846,8 +1027,8 @@ end
Unwraps a reactive proxy to get the original table. If the provided value Unwraps a reactive proxy to get the original table. If the provided value
is not a proxy, it is returned as-is. is not a proxy, it is returned as-is.
@param value A reactive proxy or a non-proxy value. @param value A reactive proxy or a non-proxy value
@return The unwrapped target value. @return The source table if `value` is a reactive proxy
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]] ]]
local function toRaw<T>(value: T): T local function toRaw<T>(value: T): T
@ -880,9 +1061,9 @@ end
end) end)
``` ```
@param initialValue The value to make deeply reactive. @param initialValue Source table to make deeply reactive
@return A proxy table that tracks reads to nested properties. @return A proxy table that tracks reads to nested properties
@return A function for updating the original table. @return A function for batching mutations to the source table
@see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue @see https://github.com/littensy/charm?tab=readme-ov-file#reactiveinitialvalue
]] ]]
local function reactive<T>(initialValue: T & {}, trackParent: (() -> ())?): (T, (update: ((T) -> ())?) -> ()) 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 Wraps a value in `reactive()` if it is a table; otherwise, returns the
value as-is. value as-is.
@param value A value to make reactive. @param value Source value to make deeply reactive
@param trackParent An optional tracking function to propagate deep updates. @param trackParent Optional tracking function to propagate deep updates
@return A reactive proxy if the value is a table. @return A reactive proxy if the value is a table
]] ]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) else value return if type(value) == "table" then reactive(value, trackParent) else value
@ -974,30 +1155,22 @@ function ReactiveProxy.__len(self: ReactiveProxy)
return #self.__target return #self.__target
end end
local reactiveSystem = system.createReactiveSystem({ link, unlink, propagate, checkDirty, shallowPropagate = system.createReactiveSystem({
update = update, update = update,
notify = notify, notify = notify,
unwatched = unwatched, unwatched = unwatched,
}) })
link = reactiveSystem.link
unlink = reactiveSystem.unlink
propagate = reactiveSystem.propagate
checkDirty = reactiveSystem.checkDirty
shallowPropagate = reactiveSystem.shallowPropagate
return { return {
ReactiveFlags = system.ReactiveFlags, -- Core reactive primitives
flags = flags,
getActiveSub = getActiveSub,
setActiveSub = setActiveSub,
startBatch = startBatch,
endBatch = endBatch,
signal = signal, signal = signal,
computed = computed, computed = computed,
effect = effect, effect = effect,
effectScope = effectScope, effectScope = effectScope,
trigger = trigger, trigger = trigger,
-- Charm utilities
flags = flags,
atom = atom, atom = atom,
onCleanup = onCleanup, onCleanup = onCleanup,
untracked = untracked, untracked = untracked,
@ -1011,7 +1184,16 @@ return {
getter: () -> { [KI]: VI }, getter: () -> { [KI]: VI },
mapper: (item: VI, key: KI) -> (VO, KO) mapper: (item: VI, key: KI) -> (VO, KO)
) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }), ) -> () -> { [KO]: VO }) & (<K, VI, VO>(getter: () -> { [K]: VI }, mapper: (item: VI, key: K) -> VO) -> () -> { [K]: VO }),
-- Reactive proxies
reactive = reactive, reactive = reactive,
isReactive = isReactive, isReactive = isReactive,
toRaw = toRaw, 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 Reactive system adapted from alien-signals at
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts 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 = { export type ReactiveNode = {
@ -249,13 +227,7 @@ local function createReactiveSystem(props: {
return false return false
end end
return { return link, unlink, propagate, checkDirty, shallowPropagate
link = link,
unlink = unlink,
propagate = propagate,
checkDirty = checkDirty,
shallowPropagate = shallowPropagate,
}
end end
return { return {

View file

@ -4,4 +4,4 @@ rojo = "rojo-rbx/rojo@7.6.1"
stylua = "johnnymorganz/stylua@2.3.1" stylua = "johnnymorganz/stylua@2.3.1"
selene = "Kampfkarren/selene@0.29.0" selene = "Kampfkarren/selene@0.29.0"
luau-lsp = "johnnymorganz/luau-lsp@1.57.1" 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"