This commit is contained in:
Kaan 2026-10-19 14:27:25 +00:00 • committed by GitHub
commit a50777c390
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 134 additions and 42 deletions

View file

@ -98,15 +98,20 @@ local function push_cleanup<T>(node: Node<T>, cleanup: () -> ())
end end
end end
local function flush_cleanups<T>(node: Node<T>) -- runs every cleanup even if one errors, returns the first error
local function flush_cleanups<T>(node: Node<T>): string?
local first_err: string?
if node.cleanups then if node.cleanups then
for _, fn in node.cleanups do for _, fn in node.cleanups do
local ok, err: string? = xpcall(fn, debug.traceback) local ok, err: string? = xpcall(fn, debug.traceback)
if not ok then error(`cleanup error: {err}`, 0) end if not ok and not first_err then first_err = `cleanup error: {err}` end
end end
table.clear(node.cleanups) table.clear(node.cleanups)
end end
return first_err
end end
local function find_and_swap_pop<T>(t: { T }, v: T) local function find_and_swap_pop<T>(t: { T }, v: T)
@ -125,12 +130,15 @@ local function unparent<T>(node: Node<T>)
end end
end end
local function destroy<T>(node: Node<T>) local destroy_owned: <T>(node: Node<T>) -> string?
-- always fully destroys the node, returns the first cleanup error
local function destroy_node<T>(node: Node<T>): string?
if flags.strict and table.find(scopes, node) then if flags.strict and table.find(scopes, node) then
error("attempt to destroy an active scope", 0) error("attempt to destroy an active scope", 0)
end end
flush_cleanups(node) local err = flush_cleanups(node)
unparent(node) unparent(node)
if node.owner then if node.owner then
@ -138,70 +146,85 @@ local function destroy<T>(node: Node<T>)
node.owner = false node.owner = false
end end
if node.owned then local owned_err = destroy_owned(node)
local owned = node.owned return err or owned_err
while owned[1] do destroy(owned[1]) end
end
end end
local function destroy_owned<T>(node: Node<T>) function destroy_owned<T>(node: Node<T>): string?
local err: string?
if node.owned then if node.owned then
local owned = node.owned local owned = node.owned
while owned[1] do destroy(owned[1]) end while owned[1] do
local owned_err = destroy_node(owned[1])
err = err or owned_err
end end
end
return err
end
local function destroy<T>(node: Node<T>)
local err = destroy_node(node)
if err then error(err, 0) end
end end
local update_queue = { n = 0 } :: { n: number, [number]: Node<any> } local update_queue = { n = 0 } :: { n: number, [number]: Node<any> }
local function evaluate_node<T>(node: Node<T>) -- returns whether the cached value changed, and an error instead of throwing
local function try_evaluate_node<T>(node: Node<T>): (boolean, string?)
if flags.strict then if flags.strict then
if table.find(scopes, node) then if table.find(scopes, node) then
error("a scope, that should rerun due to the update of a source, is already active", 0) return false, "a scope, that should rerun due to the update of a source, is already active"
end end
local initial_value = node.cache local initial_value = node.cache
local err: string?
for i = 1, 2 do for i = 1, 2 do
local cur_value = node.cache local cur_value = node.cache
flush_cleanups(node) local cleanup_err = flush_cleanups(node)
destroy_owned(node) local owned_err = destroy_owned(node)
err = err or cleanup_err or owned_err
push_scope(node) push_scope(node)
local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) local ok, new_value = ycall(node.effect :: (T) -> T, cur_value)
pop_scope() pop_scope()
if not ok then if not ok then
table.clear(update_queue) return false, `effect error stacktrace\n{new_value :: string}`
update_queue.n = 0
error(`effect error stacktrace\n{new_value :: string}`, 0)
end end
node.cache = new_value :: T node.cache = new_value :: T
end end
return initial_value ~= node.cache return initial_value ~= node.cache, err
else else
local cur_value = node.cache local cur_value = node.cache
flush_cleanups(node) local cleanup_err = flush_cleanups(node)
destroy_owned(node) local owned_err = destroy_owned(node)
push_scope(node) push_scope(node)
local ok, new_value = xpcall(node.effect :: (T) -> T, effect_traceback, node.cache) local ok, new_value = xpcall(node.effect :: (T) -> T, effect_traceback, node.cache)
pop_scope() pop_scope()
if not ok then if not ok then
table.clear(update_queue) return false, new_value
update_queue.n = 0
error(new_value, 0)
end end
node.cache = new_value node.cache = new_value
return cur_value ~= new_value return cur_value ~= new_value, cleanup_err or owned_err
end end
end end
local function evaluate_node<T>(node: Node<T>): boolean
local changed, err = try_evaluate_node(node)
if err then error(err, 0) end
return changed
end
local function queue_children_for_update<T>(node: SourceNode<T>) local function queue_children_for_update<T>(node: SourceNode<T>)
local i = update_queue.n local i = update_queue.n
while node[1] do while node[1] do
@ -216,21 +239,30 @@ local function get_update_queue_length()
return update_queue.n return update_queue.n
end end
-- queued nodes are already unparented, so every one must be evaluated even if
-- another errors; the first error is thrown after the queue is flushed
local function flush_update_queue(from: number) local function flush_update_queue(from: number)
local first_err: string?
local i = from + 1 local i = from + 1
while i <= update_queue.n do while i <= update_queue.n do
local node = update_queue[i] local node = update_queue[i]
--assert(node.effect) --assert(node.effect)
if node.owner and evaluate_node(node) then -- check if node is still owned in case destroyed after queued
queue_children_for_update(node) if node.owner then
local changed, err = try_evaluate_node(node)
if changed then queue_children_for_update(node) end
first_err = first_err or err
end end
update_queue[i] = false :: any update_queue[i] = false :: any -- false instead of nil to avoid sparse
i += 1 i += 1
end end
update_queue.n = from update_queue.n = from
if first_err then error(first_err, 0) end
end end
local function update_descendants<T>(root: SourceNode<T>) local function update_descendants<T>(root: SourceNode<T>)
@ -239,21 +271,7 @@ local function update_descendants<T>(root: SourceNode<T>)
if flags.batch then return end if flags.batch then return end
local i = n0 + 1 flush_update_queue(n0)
while i <= update_queue.n do
local node = update_queue[i]
--assert(node.effect)
-- check if node is still owned in case destroyed after queued
if node.owner and evaluate_node(node) then
queue_children_for_update(node)
end
update_queue[i] = false :: any -- false instead of nil to avoid sparse
i += 1
end
update_queue.n = n0
end end
local function push_scope_as_child_of<T>(node: SourceNode<T>) local function push_scope_as_child_of<T>(node: SourceNode<T>)

View file

@ -633,6 +633,25 @@ TEST("effect()", wrap_root(function()
CHECK(count == 2) CHECK(count == 2)
end end
do CASE "error does not stop other effects"
local src = source(0)
local count = 0
effect(function()
if src() == 1 then error("effect error") end
end)
effect(function()
src()
count += 1
end)
CHECK(count == 1)
CHECK(not pcall(src, 1))
CHECK(count == 2)
src(2)
CHECK(count == 3)
end
end)) end))
TEST("cleanup()", wrap_root(function() TEST("cleanup()", wrap_root(function()
@ -709,6 +728,46 @@ TEST("cleanup()", wrap_root(function()
CHECK(ran.destroy) CHECK(ran.destroy)
CHECK(ran.Destroy) CHECK(ran.Destroy)
end end
do CASE "error does not stop destruction"
local src = source(0)
local cleaned = false
local count = 0
local destroy = root(function()
cleanup(function() error("cleanup error") end)
cleanup(function() cleaned = true end)
effect(function()
src()
count += 1
end)
end)
CHECK(not pcall(destroy))
CHECK(cleaned)
src(1)
CHECK(count == 1)
end
do CASE "error on rerun does not stop updates"
local src = source(0)
local count = 0
effect(function()
src()
cleanup(function() if src() == 1 then error("cleanup error") end end)
end)
effect(function()
src()
count += 1
end)
CHECK(not pcall(src, 1))
CHECK(count == 2)
src(2)
CHECK(count == 3)
end
end)) end))
TEST("create()", wrap_root(function() TEST("create()", wrap_root(function()
@ -3112,6 +3171,21 @@ TEST("strict", wrap_root(function()
CHECK(count == 4) CHECK(count == 4)
end end
do CASE "effect updating its own source"
local src = source(0)
local count = 0
effect(function()
count += 1
if count > 100 then return end
local v = src()
if v > 0 then src(v + 1) end
end)
CHECK(not pcall(src, 1))
CHECK(count < 100)
end
do CASE "destruction of active scope" do CASE "destruction of active scope"
local src = source(false) local src = source(false)
local count = 0 local count = 0