feat: update alien-signals, add recursion flag

This commit is contained in:
littensy 2025-11-05 00:16:18 -08:00
parent dffeb35933
commit 96ab2317f2
8 changed files with 100 additions and 30 deletions

View file

@ -269,6 +269,27 @@ test("should handle flags are indirectly updated during checkDirty", function()
assert(triggers == 2, "did not update indirectly")
end)
test("should handle effect recursion for the first execution", function()
local src1, setSrc1 = signal(0)
local src2, setSrc2 = signal(0)
local triggers1 = 0
local triggers2 = 0
effect(function()
triggers1 += 1
setSrc1(math.min(src1() + 1, 5))
end)
effect(function()
triggers2 += 1
setSrc2(math.min(src2() + 1, 5))
src2()
end)
assert(triggers1 == 1, `bad triggers1: {triggers1}`)
assert(triggers2 == 1, `bad triggers2: {triggers2}`)
end)
test("should run cleanups in sequence", function()
local order = {}

View file

@ -90,6 +90,29 @@ test("should queue addition and removal", function()
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
end)
test("should handle recursive additions and removals", function()
local items, setItems = signal({ 1 })
local edits = {}
observe(items, function(item)
if item < 5 then
setItems(function(state)
state = table.clone(state)
table.insert(state, item + 1)
return state
end)
else
setItems({})
end
table.insert(edits, `+{item}`)
return function()
table.insert(edits, `-{item}`)
end
end)
assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
end)
test("should handle self removal", function()
local items, setItems = signal({ 1, 2, 3, 4 })
local edits = {}

View file

@ -1,3 +1,4 @@
local globals = require("./globals")
--[[
Reactive system adapted from alien-signals at
https://github.com/stackblitz/alien-signals/blob/master/src/system.ts
@ -167,10 +168,17 @@ local function createReactiveSystem(props: {
end
local function propagate(link: Link): ()
local allowRecursion = globals.allowRecursion
repeat
local sub = link.sub
local flags = sub.flags
if allowRecursion then
flags = bit32.band(flags, bit32.bnot(bit32.bor(RECURSED, RECURSED_CHECK)))
sub.flags = flags
end
if bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED, DIRTY, PENDING)) == 0 then
sub.flags = bit32.bor(flags, PENDING)
elseif bit32.band(flags, bit32.bor(RECURSED_CHECK, RECURSED)) == 0 then
@ -185,7 +193,7 @@ local function createReactiveSystem(props: {
flags = NONE
end
if bit32.btest(flags, WATCHING) then
if bit32.band(flags, bit32.bor(WATCHING, RECURSED_CHECK)) == WATCHING then
notify(sub)
end

View file

@ -51,7 +51,7 @@ type EffectScope = ReactiveNode & {
}
type Effect = ReactiveNode & {
fn: () -> (() -> ())?,
fn: () -> ...(() -> ())?,
cleanups: { () -> () },
}
@ -116,7 +116,7 @@ local function run(e: Effect): ()
e.depsTail = nil
e.flags = bit32.bor(WATCHING, RECURSED_CHECK)
local prevSub = setActiveSub(e)
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(e.fn)
local success, result = (if globals.strict then noYield else pcall)(e.fn)
activeSub = prevSub
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(e)
@ -162,7 +162,7 @@ local function updateComputed(c: Computed): boolean
c.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(c)
local oldValue = c.value
local success, result: unknown = (if globals.strict then noYield else pcall)(c.getter, oldValue)
local success, result = (if globals.strict then noYield else pcall)(c.getter, oldValue)
activeSub = prevSub
c.flags = bit32.band(c.flags, bit32.bnot(RECURSED_CHECK))
purgeDeps(c)
@ -209,10 +209,11 @@ local function computedOper<T>(this: Computed<T>): () -> T
end
end
elseif flags == NONE then
this.flags = MUTABLE
this.flags = bit32.bor(MUTABLE, RECURSED_CHECK)
local prevSub = setActiveSub(this)
local success, result = pcall(this.getter)
activeSub = prevSub
this.flags = bit32.band(this.flags, bit32.bnot(RECURSED_CHECK))
if success then
this.value = result
else
@ -235,7 +236,7 @@ local function signalOper<T>(
local value: T
if type(update) == "function" then
local prevSub = setActiveSub(nil)
local success, result: T = (if globals.strict then noYield else pcall)(update, this.pendingValue)
local success, result = (if globals.strict then noYield else pcall)(update, this.pendingValue)
activeSub = prevSub
if not success then
error(result)
@ -365,18 +366,19 @@ end
--- @param fn The function to run when dependencies change.
--- @return A function for disposing the effect.
--- @see https://github.com/littensy/charm?tab=readme-ov-file#effectcallback
local function effect(fn: () -> ()): () -> ()
local function effect(fn: () -> ...(() -> ())?): () -> ()
local e: Effect = {
fn = fn,
flags = WATCHING,
flags = bit32.bor(WATCHING, RECURSED_CHECK),
cleanups = {},
}
local prevSub = setActiveSub(e)
if prevSub and (globals.trackInnerEffects or prevSub.flags == NONE) then
link(e, prevSub, 0)
end
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn)
local success, result = (if globals.strict then noYield else pcall)(fn)
activeSub = prevSub
e.flags = bit32.band(e.flags, bit32.bnot(RECURSED_CHECK))
if not success then
error(result)
elseif result then
@ -392,7 +394,7 @@ end
--- @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.
--- @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: EffectScope = {
flags = NONE,
cleanups = {},
@ -401,7 +403,7 @@ local function effectScope(fn: () -> (), detached: boolean?): () -> ()
if prevSub and not detached then
link(e, prevSub, 0)
end
local success, result: (() -> ())? = (if globals.strict then noYield else pcall)(fn)
local success, result = (if globals.strict then noYield else pcall)(fn)
activeSub = prevSub
if not success then
error(result)

View file

@ -17,4 +17,8 @@ return {
--- when the parent effect re-runs. This is usually desirable, but can be
--- disabled to revert to the old behavior. Defaults to `true`.
trackInnerEffects = true,
--- Whether to allow recursive updates in effects and computed signals.
--- Disabling this can help catch certain classes of bugs, but may also
--- cause valid code to error. Defaults to `false`.
allowRecursion = false,
}

View file

@ -36,6 +36,12 @@ export const globals: {
* disabled to revert to the old behavior. Defaults to `true`.
*/
trackInnerEffects: boolean;
/**
* Whether to allow recursive updates in effects and computed signals.
* Disabling this can help catch certain classes of bugs, but may also
* cause valid code to error. Defaults to `false`.
*/
allowRecursion: boolean;
};
/**

View file

@ -42,16 +42,19 @@ local function observe<K, V>(getter: () -> { [K]: V }, callback: (item: V, key:
return effectScope(function()
effect(function()
-- Batch so that side effects run after all scopes are created. This
-- ensures that a scope creating or removing items doesn't interfere
-- with the current update cycle.
batch(updateScopes, getter())
local data = getter()
-- Effects can't trigger themselves more than once, so if a scope
-- creates or removes items, we need to check for updates again. The
-- way we do this abuses a weird property of signal accesses that
-- causes the effect to re-run.
getter()
-- Batch so that side effects run after all scopes are created.
-- This ensures that a scope creating or removing items doesn't
-- interfere with the current update cycle.
batch(updateScopes, data)
-- Effects can't trigger themselves, so if a scope creates or
-- removes items, we need to check for updates again.
local newData = getter()
if newData ~= data then
batch(updateScopes, newData)
end
end)
return function()