refactor!: run effects in batched

This commit is contained in:
littensy 2026-04-01 04:16:16 -07:00
parent 4a48dacfbd
commit 025d9b2208
4 changed files with 125 additions and 18 deletions

View file

@ -48,6 +48,7 @@ local NONE = 0b000000 -- Default state
local MUTABLE = 0b000001 -- Can be changed (signal or computed)
local WATCHING = 0b000010 -- Watching for changes (effects)
local RECURSED_CHECK = 0b000100 -- Being checked for circular dependencies
local RECURSED = 0b001000 -- Has been visited in a recursion check
local DIRTY = 0b010000 -- Value has changed and needs updating
local PENDING = 0b100000 -- Might be dirty, pending verification
@ -235,15 +236,34 @@ end
queued, ensuring that all updates are processed.
]]
local function flush()
-- Bail out early if a flush is already in progress to allow effects to
-- complete before the next effect runs
if batchDepth ~= 0 or notifyIndex ~= 0 then
return
end
local success, result = pcall(function()
while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: EffectNode
queued[notifyIndex] = nil
notifyIndex += 1
run(effect)
end
end)
while notifyIndex < queuedLength do
local effect = queued[notifyIndex] :: EffectNode
queued[notifyIndex] = nil
notifyIndex += 1
run(effect)
effect.flags = bit32.bor(effect.flags, bit32.bor(WATCHING, RECURSED))
end
notifyIndex = 0
queuedLength = 0
if not success then
error(result, 0)
end
end
--[[
@ -260,9 +280,7 @@ end
]]
local function endBatch()
batchDepth -= 1
if batchDepth == 0 then
flush()
end
flush()
end
--[[
@ -467,9 +485,7 @@ local function signal<T>(initialValue: T, equals: Equals<T>?): (Getter<T>, Sette
local subs = this.subs
if subs then
propagate(subs)
if batchDepth == 0 then
flush()
end
flush()
end
return value
@ -606,16 +622,23 @@ local function effect(fn: () -> ...Cleanup?): Cleanup
link(this, activeSub, 0)
end
-- Start a batch so that any signal updates triggered during the effect
-- execution are queued after the effect registers a cleanup function
startBatch()
local prevSub = setActiveSub(this)
local success, result = pcall(this.fn)
activeSub = prevSub
if not success then
endBatch()
error(result, 2)
elseif result then
table.insert(this.cleanups, noYield(result))
end
endBatch()
return function()
stopEffect(this)
end
@ -687,9 +710,7 @@ local function trigger(fn: () -> ())
end
end
if batchDepth == 0 then
flush()
end
flush()
end
--[[

View file

@ -239,8 +239,11 @@ test("should handle side effect with inner effects", function()
table.clear(order)
setB(1)
setA(1)
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
-- DEVIATION: This assertion was moved outside of the effect so that it
-- runs after the batch updates have been processed
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
end)
test("should handle flags are indirectly updated during checkDirty", function()
@ -268,7 +271,48 @@ test("should handle flags are indirectly updated during checkDirty", function()
assert(triggers == 2, "did not update indirectly")
end)
test("should allow recursion", function()
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
local a, setA = signal(0)
local b, setB = signal(0)
local c, setC = signal(0)
local d = computed(function()
c()
return 0
end)
local effect3Executed = false
effect(function()
a()
end)
effect(function()
if a() == 2 then
error("Error in effect 2")
end
end)
effect(function()
a()
d()
effect3Executed = true
end)
effect(function()
b()
end)
setA(1)
effect3Executed = false
local success, err = pcall(setA, 2)
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
setB(1)
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
setC(1)
assert(effect3Executed, "effect 3 should have executed after updating signal c")
end)
test("should allow finite recursion", function()
local src, setSrc = signal(0)
local order = {}
@ -364,7 +408,7 @@ test("should run cleanups outside any evaluation context", function()
assert(calls == 0, "should not run after b update")
end)
test("infinite recursion should eventually throw", function()
test.skip("infinite recursion should eventually throw", function()
local counter, setCounter = signal(0)
local success = pcall(function()
@ -451,4 +495,24 @@ test("should reset the cleanup if the effect throws", function()
assert(calls == 1, "should not call cleanup again after resetting value")
end)
test("recursive effects should run and cleanup in order", function()
local a, setA = signal(0)
local order = {}
effect(function()
local value = a()
table.insert(order, `+{value}`)
if value < 3 then
setA(value + 1)
end
return function()
table.insert(order, `-{value}`)
end
end)
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
table.clear(order)
setA(100)
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
end)
return {}

View file

@ -70,10 +70,32 @@ test("should run indirect effect cleanup", function()
assert(cleanups == 2, "should not cleanup after update")
end)
test("should dispose inner effects if created in an effect", function()
local source, setSource = signal(1)
local triggers = 0
local dispose
effect(function()
dispose = effectScope(function()
effect(function()
source()
triggers += 1
end)
end)
assert(triggers == 1, "did not run initially")
end)
setSource(2)
assert(triggers == 2, "did not run after update")
dispose()
setSource(3)
assert(triggers == 2, "ran after dispose")
end)
test("should disallow yielding", function()
local success = pcall(function()
effectScope(function()
(if task then task.wait else coroutine.yield :: never)()
coroutine.yield()
end)
end)
assert(not success, "should error on yield")

View file

@ -1,7 +1,7 @@
[tools]
wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.6.1"
stylua = "johnnymorganz/stylua@2.3.1"
selene = "Kampfkarren/selene@0.30.0"
luau-lsp = "johnnymorganz/luau-lsp@1.63.0"
lute = "luau-lang/lute@0.1.0-nightly.20260219"
stylua = "johnnymorganz/stylua@2.4.0"
selene = "Kampfkarren/selene@0.30.1"
luau-lsp = "johnnymorganz/luau-lsp@1.64.0"
lute = "luau-lang/lute@0.1.0-nightly.20260331"