Prevent duplicate updates

This commit is contained in:
Ian 2026-07-29 23:58:20 -04:00
parent 452060a533
commit a2a1f89926
3 changed files with 171 additions and 109 deletions

View file

@ -7,7 +7,7 @@ export type SourceNode<T> = {
export type Node<T> = { export type Node<T> = {
cache: T, cache: T,
effect: ((T) -> T) | false, effect: ((T) -> T) | false,
cleanups: { () -> () } | false, cleanups: { () -> () } | false,
context: { [number]: unknown } | false, context: { [number]: unknown } | false,
@ -25,13 +25,11 @@ local function efn(err: string)
local trace = debug.traceback(err, 2) local trace = debug.traceback(err, 2)
if string.find(err, "^effect error stacktrace") then -- if effect error is nested if string.find(err, "^effect error stacktrace") then -- if effect error is nested
trace = string.gsub(" " .. trace, "\n", function() -- indent entire error trace = string.gsub(" " .. trace, "\n", "\n ") -- indent entire error
return "\n "
end)
end end
trace ..= "\nsource update stacktrace:" trace ..= "\nsource update stacktrace:"
return trace return trace
end end
local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U) local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U)
@ -41,7 +39,7 @@ local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U)
local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg)
assert(resume_ok) assert(resume_ok)
if coroutine.status(thread) ~= "dead" then if coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "attempt to yield in reactive scope") return false, debug.traceback(thread, "attempt to yield in reactive scope")
end end
@ -125,7 +123,7 @@ local function destroy<T>(node: Node<T>)
flush_cleanups(node) flush_cleanups(node)
unparent(node) unparent(node)
if node.owner then if node.owner then
find_and_swap_pop(node.owner.owned :: { Node<T> }, node) find_and_swap_pop(node.owner.owned :: { Node<T> }, node)
node.owner = false node.owner = false
@ -144,9 +142,19 @@ local function destroy_owned<T>(node: Node<T>)
end end
end end
local update_queue = { n = 0 } :: { n: number, [number]: Node<any> } local update_queue_n = 0
local in_queue = {}
local update_queue = {} :: { [number]: Node<any> }
local node_deps_left = {} -- any node that goes into the update queue needs to store how many dependencies it's waiting on
local function evaluate_node<T>(node: Node<T>) local function reset_queue()
update_queue_n = 0
table.clear(in_queue)
table.clear(update_queue)
table.clear(node_deps_left)
end
local function evaluate_node<T>(node: Node<T>, continue_on_error: boolean) -- if continue_on_error, the calling function wishes to continue even if the update fails (without using pcall)
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) error("a scope, that should rerun due to the update of a source, is already active", 0)
@ -159,15 +167,19 @@ local function evaluate_node<T>(node: Node<T>)
flush_cleanups(node) flush_cleanups(node)
destroy_owned(node) destroy_owned(node)
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) local msg = debug.traceback(`effect error: {new_value}`, 2)
update_queue.n = 0 if continue_on_error then
error(`effect error stacktrace\n{new_value :: string}`, 0) task.spawn(error, msg, 0)
return false
else
error(msg, 2)
end
end end
node.cache = new_value :: T node.cache = new_value :: T
@ -181,72 +193,123 @@ local function evaluate_node<T>(node: Node<T>)
destroy_owned(node) destroy_owned(node)
push_scope(node) push_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) local ok, new_value = pcall(node.effect :: (T) -> T, cur_value)
pop_scope() pop_scope()
if not ok then if not ok then
table.clear(update_queue) local msg = debug.traceback(`effect error: {new_value}`, 2)
update_queue.n = 0 if continue_on_error then
error(`effect error:\n{new_value}\n`, 0) task.spawn(error, msg, 0)
return false
else
error(msg, 2)
end
end end
node.cache = new_value node.cache = new_value
return cur_value ~= new_value return cur_value ~= new_value
end end
end end
local function queue_children_for_update<T>(node: SourceNode<T>) local function add_dependency_counts(node)
local i = update_queue.n for i = 1, #node do
while node[1] do local child = node[i]
i += 1 if not child.owner then continue end -- we won't be updating this anyway
update_queue[i] = node[1] local deps_left = node_deps_left[child]
unparent(node[1]) if deps_left then
node_deps_left[child] = deps_left + 1
else
node_deps_left[child] = 1
add_dependency_counts(child) -- only recurse if we haven't seen this node before
end
end
end
local function remove_dependency_counts(node) -- to be called when updating a node if its value remains unchanged
for i = 1, #node do
local child = node[i]
if not node.owner then continue end -- we won't be updating this anyway
node_deps_left[child] -= 1
remove_dependency_counts(child)
end
end
local function queue_children_for_update<T>(node: SourceNode<T>)
for i = 1, #node do
local child = node[i]
node_deps_left[child] -= 1
if not in_queue[child] then
in_queue[child] = true
update_queue_n += 1
update_queue[update_queue_n] = child
end
end end
update_queue.n = i
end end
local function get_update_queue_length() local function get_update_queue_length()
return update_queue.n return update_queue_n
end end
local function flush_update_queue(from: number) local function flush_update_queue(from: number)
local i = from + 1 while true do
while i <= update_queue.n do local i = from
local node = update_queue[i] if i >= update_queue_n then return end -- nothing more to do
--assert(node.effect) local changed = false
local moveTo1 = from -- 1 less than where to store an update if we can't process it yet
while i < update_queue_n do
i += 1
local node = update_queue[i]
-- assert(node.effect)
if node.owner then
local deps_left = node_deps_left[node]
if deps_left > 0 then -- waiting on more dependencies
moveTo1 += 1
if i ~= moveTo1 then
update_queue[moveTo1] = node
update_queue[i] = false :: any
end
continue
end
changed = true
-- Reset in_queue and node_deps_left before evaluation in case the node's depedencies are changed during evaluation (which usually implies an infinite loop, but we have explicit errors for this case)
in_queue[node] = nil
node_deps_left[node] = nil
unparent(node)
if evaluate_node(node, true) then
queue_children_for_update(node)
else
remove_dependency_counts(node)
end
else -- otherwise node was cleaned up
in_queue[node] = nil
node_deps_left[node] = nil
end
if node.owner and evaluate_node(node) then update_queue[i] = false :: any
queue_children_for_update(node) end
if moveTo1 == 0 then -- no pending updates
reset_queue()
return
else
update_queue_n = moveTo1
if not changed then -- Can occur in recursive updates (where 'from' is > 0)
if from == 0 then
warn("Some nodes failed to update", update_queue, node_deps_left, debug.traceback())
reset_queue()
end
return
end
end end
update_queue[i] = false :: any
i += 1
end end
update_queue.n = from
end end
local function update_descendants<T>(root: SourceNode<T>) local function update_descendants<T>(root: SourceNode<T>)
local n0 = update_queue.n add_dependency_counts(root)
local n0 = update_queue_n
queue_children_for_update(root) queue_children_for_update(root)
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

@ -116,7 +116,7 @@ TEST("graph", function()
effect(c.cache) effect(c.cache)
pop_scope() pop_scope()
CHECK(count == 1) CHECK(count == 1)
update_descendants(a) update_descendants(a)
CHECK(count == 2) CHECK(count == 2)
@ -188,7 +188,7 @@ TEST("graph", function()
end) end)
end end
do push_scope(root) do push_scope(root)
clean "root" clean "root"
items_updated = node(root) items_updated = node(root)
push_scope_as_child_of(items_updated) -- should not push_scope_as_child_of(items_updated) -- should not
@ -216,9 +216,9 @@ TEST("graph", function()
pop_scope() end pop_scope() end
pop_scope() end pop_scope() end
pop_scope() end pop_scope() end
pop_scope() end pop_scope() end
pop_scope() end pop_scope() end
-- verify graph -- verify graph
@ -226,7 +226,7 @@ TEST("graph", function()
local c = get_children(items_updated) local c = get_children(items_updated)
CHECK(#c == 0) CHECK(#c == 0)
end end
do do
local c = get_children(root) local c = get_children(root)
CHECK(#c == 0) CHECK(#c == 0)
@ -298,7 +298,7 @@ TEST("graph", function()
depth=1 depth=1
_, _ <- attempt to update_descendants nothing _, _ <- attempt to update_descendants nothing
^ ^
]] ]]
@ -466,7 +466,7 @@ TEST("derive()", wrap_root(function()
is_even() is_even()
count += 1 count += 1
end) end)
num(1) -- odd num(1) -- odd
CHECK(count == 2) CHECK(count == 2)
num(2) -- even num(2) -- even
@ -559,7 +559,7 @@ TEST("derive()", wrap_root(function()
-- end) -- end)
-- destroy() -- destroy()
-- CHECK(ran[1] == 1) -- CHECK(ran[1] == 1)
-- CHECK(ran[2] == 1) -- CHECK(ran[2] == 1)
-- CHECK(cleaned[1] == 1) -- CHECK(cleaned[1] == 1)
@ -567,7 +567,7 @@ TEST("derive()", wrap_root(function()
-- end -- end
do CASE "garbage collection" do CASE "garbage collection"
-- check that `b` does not allow gc of `a` -- check that `b` does not allow gc of `a`
local a = source(1) local a = source(1)
local _b = derive(function() local _b = derive(function()
@ -606,7 +606,7 @@ TEST("effect()", wrap_root(function()
local num = source(0) local num = source(0)
local text = derive(function() return tostring(num()) end) local text = derive(function() return tostring(num()) end)
local count = 0 local count = 0
effect(function() effect(function()
text() text()
@ -660,7 +660,7 @@ TEST("cleanup()", wrap_root(function()
src() src()
effected += 1 effected += 1
cleanup(function() cleanup(function()
cleaned += 1 cleaned += 1
end) end)
end) end)
@ -677,7 +677,7 @@ TEST("cleanup()", wrap_root(function()
local src = source(1) local src = source(1)
local queue = {} local queue = {}
effect(function() effect(function()
src() src()
cleanup(function() table.insert(queue, 1) end) cleanup(function() table.insert(queue, 1) end)
@ -772,7 +772,7 @@ TEST("create()", wrap_root(function()
vide.defer_nested_properties = false vide.defer_nested_properties = false
local t = {} local t = {}
create "TextLabel" { create "TextLabel" {
{ {
{ function() table.insert(t, 1) end } :: any, { function() table.insert(t, 1) end } :: any,
@ -891,7 +891,7 @@ TEST("create()", wrap_root(function()
do CASE "bind children" do CASE "bind children"
local children = source() local children = source()
local a, b, c = local a, b, c =
create "TextLabel" { Name = "A" }, create "TextLabel" { Name = "A" },
create "TextLabel" { Name = "B" }, create "TextLabel" { Name = "B" },
create "TextLabel" { Name = "C" } create "TextLabel" { Name = "C" }
@ -899,14 +899,14 @@ TEST("create()", wrap_root(function()
local frame = create "Frame" { local frame = create "Frame" {
children children
} }
children { a, b } children { a, b }
CHECK(frame:FindFirstChild "A") CHECK(frame:FindFirstChild "A")
CHECK(frame:FindFirstChild "B") CHECK(frame:FindFirstChild "B")
-- check that b is removed and c is added while a remains untouched -- check that b is removed and c is added while a remains untouched
children { a, c } children { a, c }
CHECK(frame:FindFirstChild "A") CHECK(frame:FindFirstChild "A")
@ -949,7 +949,7 @@ TEST("create()", wrap_root(function()
local set_test_to_true = action(function(self) (self :: any).test = true end) local set_test_to_true = action(function(self) (self :: any).test = true end)
local f2 local f2
local to_apply = { local to_apply = {
{ a = 1 }, { a = 1 },
set_test_to_true, set_test_to_true,
@ -978,13 +978,13 @@ TEST("create()", wrap_root(function()
local c = create "Frame" { Name = "c" } local c = create "Frame" { Name = "c" }
local d = create "Frame" { Name = "d" } local d = create "Frame" { Name = "d" }
local e = create "Frame" { Name = "e" } local e = create "Frame" { Name = "e" }
local children = source { local children = source {
a, a,
{ b, c, { d } }, { b, c, { d } },
{ { e } } { { e } }
} }
local obj = create "Frame" { local obj = create "Frame" {
children children
} }
@ -1125,7 +1125,7 @@ TEST("show()", wrap_root(function()
show(input :: () -> number?, function(value: () -> number) show(input :: () -> number?, function(value: () -> number)
effect(function() effect(function()
local v = value() local v = value()
count += 1 count += 1
CHECK(v == count) CHECK(v == count)
@ -1166,7 +1166,7 @@ TEST("show()", wrap_root(function()
show(weapon, function(weapon: () -> Weapon) show(weapon, function(weapon: () -> Weapon)
local enchant = function() return weapon().enchant end local enchant = function() return weapon().enchant end
show(enchant, function(enchant: () -> string) show(enchant, function(enchant: () -> string)
effect(function() effect(function()
local e = enchant() local e = enchant()
@ -1295,7 +1295,7 @@ TEST("show()", wrap_root(function()
CHECK(value_upval() == true) CHECK(value_upval() == true)
CHECK(present_upval() == false) CHECK(present_upval() == false)
CHECK(not cleaned) CHECK(not cleaned)
step(0.5) step(0.5)
CHECK(output() == obj) CHECK(output() == obj)
@ -1342,7 +1342,7 @@ TEST("show()", wrap_root(function()
CHECK(value_upval() == true) CHECK(value_upval() == true)
CHECK(present_upval() == false) CHECK(present_upval() == false)
CHECK(not cleaned) CHECK(not cleaned)
step(0.5) step(0.5)
CHECK(output() == obj) CHECK(output() == obj)
@ -1432,15 +1432,15 @@ TEST("switch()", wrap_root(function()
CHECK(count == 1) CHECK(count == 1)
CHECK(output() == 1) CHECK(output() == 1)
input(false) input(false)
CHECK(output() == 0) CHECK(output() == 0)
CHECK(count == 2) CHECK(count == 2)
input(false) input(false)
CHECK(output() == 0) CHECK(output() == 0)
CHECK(count == 2) CHECK(count == 2)
input(NIL) input(NIL)
CHECK(output() == nil) CHECK(output() == nil)
end end
@ -1477,12 +1477,12 @@ TEST("switch()", wrap_root(function()
cleanup(function() owner_count += 1 end) cleanup(function() owner_count += 1 end)
local output = switch(input) { local output = switch(input) {
[true] = function() [true] = function()
cleanup(function() switch1_count += 1 end) cleanup(function() switch1_count += 1 end)
return 1 return 1
end, end,
[false] = function() [false] = function()
cleanup(function() switch0_count += 1 end) cleanup(function() switch0_count += 1 end)
return 0 return 0
end end
@ -1546,7 +1546,7 @@ TEST("indexes()", wrap_root(function()
local input = source { 1, 2, 3 } local input = source { 1, 2, 3 }
local output = indexes(input, function(v, k) local output = indexes(input, function(v, k)
return tostring(v()) return tostring(v())
end) end)
CHECK("" .. input()[1] == output()[1]) CHECK("" .. input()[1] == output()[1])
@ -1619,7 +1619,7 @@ TEST("indexes()", wrap_root(function()
input = NIL input = NIL
gc() gc()
CHECK(wref[1]) CHECK(wref[1])
end end
do -- check that `input` allows gc of `output` do -- check that `input` allows gc of `output`
@ -1645,7 +1645,7 @@ TEST("indexes()", wrap_root(function()
local input = source { 1, 2, 3 } local input = source { 1, 2, 3 }
local count = table.create(3, 0) local count = table.create(3, 0)
local output = indexes(input, function(v, i) local output = indexes(input, function(v, i)
cleanup(function() cleanup(function()
count[i] += 1 count[i] += 1
@ -1691,12 +1691,12 @@ TEST("indexes()", wrap_root(function()
indexes(items, function(item) indexes(items, function(item)
effect(function() effect(function()
item() item()
updated[1] += 1 updated[1] += 1
end) end)
effect(function() effect(function()
item() item()
updated[2] += 1 updated[2] += 1
end) end)
return {} return {}
@ -1739,7 +1739,7 @@ TEST("indexes()", wrap_root(function()
local input = source {} local input = source {}
local cleaned_counts = {} :: Map<number, number> local cleaned_counts = {} :: Map<number, number>
local output = indexes(input, function(v, i, present) local output = indexes(input, function(v, i, present)
cleanup(function() cleanup(function()
cleaned_counts[i] = (cleaned_counts[i] or 0) + 1 cleaned_counts[i] = (cleaned_counts[i] or 0) + 1
@ -1816,7 +1816,7 @@ TEST("values()", wrap_root(function()
local input = source { 1, 2, 3 } local input = source { 1, 2, 3 }
local output = values(input, function(v, k) local output = values(input, function(v, k)
return tostring(v) return tostring(v)
end) end)
CHECK("" .. input()[1] == output()[1]) CHECK("" .. input()[1] == output()[1])
@ -1894,7 +1894,7 @@ TEST("values()", wrap_root(function()
local input = source { 1, 2, 3 } local input = source { 1, 2, 3 }
local count = table.create(3, 0) local count = table.create(3, 0)
local output = values(input, function(v, i) local output = values(input, function(v, i)
cleanup(function() cleanup(function()
count[i()] += 1 count[i()] += 1
@ -1935,7 +1935,7 @@ TEST("values()", wrap_root(function()
local input = source {} local input = source {}
local cleaned_counts = {} :: Map<number, number> local cleaned_counts = {} :: Map<number, number>
local output = values(input, function(v, i, present) local output = values(input, function(v, i, present)
cleanup(function() cleanup(function()
cleaned_counts[v] = (cleaned_counts[v] or 0) + 1 cleaned_counts[v] = (cleaned_counts[v] or 0) + 1
@ -2056,10 +2056,10 @@ TEST("spring()", wrap_root(function()
do -- `input` should allow gc of `output` do -- `input` should allow gc of `output`
local input = source(10) local input = source(10)
local output = spring(input) local output = spring(input)
local wref = weak { output } local wref = weak { output }
output = NIL output = NIL
gc() gc()
CHECK(not wref[1]) CHECK(not wref[1])
end end
@ -2073,7 +2073,7 @@ TEST("spring()", wrap_root(function()
-- local output_node = unpack(capture(output)) -- local output_node = unpack(capture(output))
-- wref = weak { output_node, data } -- wref = weak { output_node, data }
-- end -- end
-- gc() -- gc()
-- CHECK(not wref[1]) -- CHECK(not wref[1])
-- CHECK(not wref[2]) -- CHECK(not wref[2])
@ -2208,11 +2208,11 @@ TEST("untrack()", wrap_root(function()
return untrack(function() return untrack(function()
return derive(function() return derive(function()
inner_count += 1 inner_count += 1
cleanup(function() cleanup(function()
cleaned_count += 1 cleaned_count += 1
end) end)
return tostring(input()) return tostring(input())
end) end)
end) end)
@ -2435,7 +2435,7 @@ TEST("batch()", wrap_root(function()
b(a()) b(a())
c(a()) c(a())
end) end)
batch(function() batch(function()
a(1) a(1)
end) end)
@ -2491,10 +2491,10 @@ TEST("batch()", wrap_root(function()
do CASE "recursive queue flush diamond A,B,C,D,E" do CASE "recursive queue flush diamond A,B,C,D,E"
--[[ --[[
where b and c batches d where b and c batches d
a > b > e a > b > e
> c > d > > c > d >
]] ]]
local a = source(0) local a = source(0)
@ -2551,7 +2551,7 @@ TEST("batch()", wrap_root(function()
--[[ --[[
a > b > d > E > G a > b > d > E > G
> c ^ > F > c ^ > F
]] ]]
@ -2560,7 +2560,7 @@ TEST("batch()", wrap_root(function()
local b = source(0) local b = source(0)
local c = source(0) local c = source(0)
local d = source(0) local d = source(0)
local e = source(0) local e = source(0)
local f = source(0) local f = source(0)
local g = source(0) local g = source(0)
@ -2668,7 +2668,7 @@ TEST("context()", function()
local ok = pcall(function() local ok = pcall(function()
ctx(1, function() end) ctx(1, function() end)
end) end)
CHECK(not ok) CHECK(not ok)
end end
@ -2847,14 +2847,14 @@ TEST("graph edge cases", wrap_root(function()
CHECK(count.b == 2) CHECK(count.b == 2)
CHECK(count.c == 2) CHECK(count.c == 2)
CHECK(count.d == 2) CHECK(count.d == 2)
CHECK(count.e == 3) -- todo: redundant re-eval CHECK(count.e == 2)
CHECK(e() == 4) CHECK(e() == 4)
a(3) a(3)
CHECK(count.b == 2) CHECK(count.b == 2)
CHECK(count.c == 3) CHECK(count.c == 3)
CHECK(count.d == 3) CHECK(count.d == 3)
CHECK(count.e == 4) CHECK(count.e == 3)
CHECK(e() == 12) CHECK(e() == 12)
end end
@ -2871,7 +2871,7 @@ TEST("graph edge cases", wrap_root(function()
end end
do CASE "do not destroy children" do CASE "do not destroy children"
local parent = source(0) local parent = source(0)
local _, local _,
destroy, destroy,
@ -2899,10 +2899,10 @@ TEST("graph edge cases", wrap_root(function()
CHECK(count == 3) CHECK(count == 3)
destroy() destroy()
update_parent_to_destroy(2) update_parent_to_destroy(2)
CHECK(count == 3) CHECK(count == 3)
parent(parent() + 1) parent(parent() + 1)
CHECK(count == 4) CHECK(count == 4)
end end
@ -2931,7 +2931,7 @@ TEST("graph edge cases", wrap_root(function()
local _, destroy_child, _child_B = nil, function() end, nil local _, destroy_child, _child_B = nil, function() end, nil
local count_A = 0 local count_A = 0
-- child_A -- child_A
effect(function() effect(function()
count_A += 1 count_A += 1

View file

@ -1,4 +1,3 @@
# todo # todo
- improve error traces - improve error traces
- prevent redundant re-eval of nodes in a complex diamond graph