Allow creation of nested tracking scopes

It turns out that handling destruction of a nested tracking scope was
not as difficult as I thought and didn't need much changes
This commit is contained in:
aaron 2023-09-25 23:37:51 +01:00
parent 38342b3805
commit c68d0a6c18
7 changed files with 128 additions and 137 deletions

View file

@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased ## Unreleased
### Changed
- Reactive scopes created within reactive scopes are now destroyed on rerun.
--- ---
## [0.1.0] - 2023-09-20 ## [0.1.0] - 2023-09-20

View file

@ -10,10 +10,14 @@ export type StartNode<T> = {
export type Node<T> = { export type Node<T> = {
cache: T, cache: T,
effect: ((T) -> T) | "owner" | "untracked", effect: ((T) -> T) | false,
cleanups: { () -> () } | false, cleanups: { () -> () } | false,
parents: { owner: StartNode<T>?, [number]: StartNode<T> },
[number]: Node<T> owned: { Node<T> } | false,
owner: Node<T> | false,
parents: { StartNode<T> },
[number]: Node<T> -- children
} }
-- reactive scope stack -- reactive scope stack
@ -50,7 +54,7 @@ local function get_owning_scope(): Node<unknown>
if not scope then if not scope then
local caller_name = debug.info(2, "n") local caller_name = debug.info(2, "n")
return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`)
elseif scope.effect ~= "owner" then elseif scope.effect then
throw("reactive scope is not an owning scope; new effects cannot be created in side-effects") throw("reactive scope is not an owning scope; new effects cannot be created in side-effects")
end end
return scope return scope
@ -62,8 +66,12 @@ local function add_child<T>(parent: StartNode<any>, child: Node<any>)
end end
local function set_owner(node: Node<any>, owner: Node<any>) local function set_owner(node: Node<any>, owner: Node<any>)
node.parents.owner = owner node.owner = owner
table.insert(owner, node) if owner.owned then
table.insert(owner.owned, node)
else
owner.owned = { node }
end
end end
local function open_scope<T>(node: Node<T>) local function open_scope<T>(node: Node<T>)
@ -96,18 +104,29 @@ local function run_cleanups<T>(node: Node<T>)
end end
end end
local function find_and_swap_pop<T>(t: { T }, v: T)
local idx = table.find(t, v)
assert(idx, "value not found")
local n = #t
t[idx] = t[n]
t[n] = nil
end
local function remove_child<T>(parent: StartNode<T>, child: Node<T>) local function remove_child<T>(parent: StartNode<T>, child: Node<T>)
local idx = table.find(parent, child) find_and_swap_pop(parent, child)
assert(idx, "child not found") end
local n = #parent
parent[idx] = parent[n] local function remove_owner<T>(node: Node<T>)
parent[n] = nil local owner = node.owner :: Node<T>
if node.owner and owner.owned then
find_and_swap_pop(owner.owned, node)
end
end end
local function unparent<T>(node: Node<T>) local function unparent<T>(node: Node<T>)
local parents = node.parents local parents = node.parents
for i, parent in ipairs(parents) do for i, parent in next, parents do
remove_child(parent, node) remove_child(parent, node)
parents[i] = nil parents[i] = nil
end end
@ -116,15 +135,21 @@ end
local function destroy<T>(node: Node<T>) local function destroy<T>(node: Node<T>)
run_cleanups(node) run_cleanups(node)
unparent(node) unparent(node)
remove_owner(node)
if node.parents.owner then if node.owned then
remove_child(node.parents.owner, node) local owned = node.owned
node.parents.owner = nil while owned[1] do destroy(owned[1]) end
end end
while node[1] do destroy(node[1]) end while node[1] do destroy(node[1]) end
end end
local function destroy_owned<T>(node: Node<T>)
if node.owned then
while node.owned[1] do destroy(node.owned[1]) 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>) local function evaluate_node<T>(node: Node<T>)
@ -132,6 +157,8 @@ local function evaluate_node<T>(node: Node<T>)
if flags.strict then if flags.strict then
run_cleanups(node) run_cleanups(node)
destroy_owned(node)
open_scope(node) open_scope(node)
local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value)
@ -141,7 +168,9 @@ local function evaluate_node<T>(node: Node<T>)
if not ok then throw(err :: string) end if not ok then throw(err :: string) end
end end
run_cleanups(node) -- todo: move in scope? run_cleanups(node)
destroy_owned(node)
open_scope(node) open_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) local ok, new_value = pcall(node.effect :: (T) -> T, cur_value)
@ -204,11 +233,15 @@ local function track<T>(node: StartNode<T>)
end end
end end
local function create_node<T>(value: T, effect: "owner" | (T) -> T): Node<T> local function create_node<T>(value: T, effect: false | (T) -> T): Node<T>
return { return {
cache = value, cache = value,
effect = effect, effect = effect,
cleanups = false, cleanups = false,
owner = false,
owned = false,
parents = {}, parents = {},
} }
end end

View file

@ -30,7 +30,7 @@ end
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO } local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = get_owning_scope() local owner = get_owning_scope()
local subowner = create_node(false, "owner") local subowner = create_node(false, false)
set_owner(subowner, owner) set_owner(subowner, owner)
local input_cache = {} :: Map<K, VI> local input_cache = {} :: Map<K, VI>
@ -67,7 +67,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
if cv ~= v then if cv ~= v then
if cv == nil then -- create new scope and run transform if cv == nil then -- create new scope and run transform
local scope = create_node(false, "owner") local scope = create_node(false, false)
scopes[i] = scope :: Node<any> scopes[i] = scope :: Node<any>
local node = create_start_node(v) local node = create_start_node(v)
@ -125,7 +125,7 @@ end
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO } local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = get_owning_scope() local owner = get_owning_scope()
local subowner = create_node(false, "owner") local subowner = create_node(false, false)
set_owner(subowner, owner) set_owner(subowner, owner)
local cur_input_cache_up = {} :: Map<VI, K> local cur_input_cache_up = {} :: Map<VI, K>
@ -156,7 +156,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local cv = cur_input_cache[v] local cv = cur_input_cache[v]
if cv == nil then -- create new scope and run transform if cv == nil then -- create new scope and run transform
local scope = create_node(false, "owner") local scope = create_node(false, false)
scopes[v] = scope :: Node<any> scopes[v] = scope :: Node<any>
local node = create_start_node(i) local node = create_start_node(i)

View file

@ -11,7 +11,7 @@ local destroy = graph.destroy
local refs = {} local refs = {}
local function root<T...>(fn: (destroy: () -> ()) -> T...): T... local function root<T...>(fn: (destroy: () -> ()) -> T...): T...
local node = create_node(false, "owner") local node = create_node(false, false)
refs[node] = true -- prevent gc of root node refs[node] = true -- prevent gc of root node

View file

@ -38,7 +38,7 @@ local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> ()
throw("map must map a value to a function") throw("map must map a value to a function")
end end
local new_scope = create_node(false, "owner") local new_scope = create_node(false, false)
last_scope = new_scope :: Node<any> last_scope = new_scope :: Node<any>
set_owner(new_scope, owner) set_owner(new_scope, owner)

View file

@ -13,7 +13,7 @@ local function untrack<T>(source: () -> T): T
-- sources are only tracked if the node in scope has an effect -- sources are only tracked if the node in scope has an effect
local effect = scope.effect local effect = scope.effect
scope.effect = "untracked" scope.effect = false
local ok, result = pcall(source) local ok, result = pcall(source)

View file

@ -51,7 +51,7 @@ TEST("graph", function()
end end
local function scope() local function scope()
return create_node(false, "owner") return create_node(false, false)
end end
local function cleanup(fn: () -> ()) local function cleanup(fn: () -> ())
@ -257,10 +257,10 @@ TEST("graph", function()
do do
local c = get_children(root) local c = get_children(root)
CHECK(#c == 3) CHECK(#c == 0)
CHECK(table.find(c, items_updated)) -- CHECK(table.find(c, items_updated))
CHECK(table.find(c, scope1 :: Node<any>)) -- CHECK(table.find(c, scope1 :: Node<any>))
CHECK(table.find(c, scope2 :: Node<any>)) -- CHECK(table.find(c, scope2 :: Node<any>))
end end
do do
@ -272,19 +272,19 @@ TEST("graph", function()
do do
local c = get_children(scope1) local c = get_children(scope1)
CHECK(#c == 1) CHECK(#c == 0)
CHECK(table.find(c, bind1)) --CHECK(table.find(c, bind1))
end end
do do
local c = get_children(scope2) local c = get_children(scope2)
CHECK(#c == 1) CHECK(#c == 0)
CHECK(table.find(c, bind2)) --CHECK(table.find(c, bind2))
end end
-- destroy -- destroy
CHECK(table.find(get_children(root), scope1 :: Node<any>)) --CHECK(table.find(get_children(root), scope1 :: Node<any>))
destroy(scope1) destroy(scope1)
CHECK(cleaned.scope1) CHECK(cleaned.scope1)
@ -293,7 +293,7 @@ TEST("graph", function()
bind1 = NIL bind1 = NIL
bind2 = NIL bind2 = NIL
gc() gc()
CHECK(#get_children(root) == 2) CHECK(#get_children(root) == 0)
CHECK(#get_children(selected) == 1) CHECK(#get_children(selected) == 1)
end end
@ -1519,7 +1519,10 @@ end))
TEST("untrack()", wrap_root(function() TEST("untrack()", wrap_root(function()
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
local derive = vide.derive
local untrack = vide.untrack local untrack = vide.untrack
local cleanup = vide.cleanup
local root = vide.root
do CASE "does not register dependency" do CASE "does not register dependency"
local a = source(0) local a = source(0)
@ -1567,69 +1570,56 @@ TEST("untrack()", wrap_root(function()
CHECK(count == 2) CHECK(count == 2)
end end
-- do CASE "outer scope" do CASE "outer scope"
-- local outer_count = 0 local outer_count = 0
-- local inner_count = 0 local inner_count = 0
-- local cleaned_count = 0 local cleaned_count = 0
-- local input = source(0) local input = source(0)
-- local output, destroy = root(function(destroy) local output, destroy = root(function(destroy)
-- local output = derive(function() local output = derive(function()
-- outer_count += 1 outer_count += 1
-- 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)
-- return output, destroy
-- end)
-- CHECK(outer_count == 1)
-- CHECK(inner_count == 1)
-- CHECK(cleaned_count == 0)
-- local output2 = output()
-- CHECK(output2() == "0")
-- input(1)
-- CHECK(outer_count == 1)
-- CHECK(inner_count == 2)
-- CHECK(cleaned_count == 1)
-- local output3 = output()
-- CHECK(output2() == "1")
-- CHECK(output3() == "1")
-- CHECK(output2 == output3)
-- destroy()
-- CHECK(cleaned_count == 2)
-- end
do CASE "cannot create effect within untrack()"
local ok = pcall(function()
effect(function()
untrack(function()
effect(function() end)
return nil
end) end)
end) end)
return output, destroy
end) end)
CHECK(not ok) CHECK(outer_count == 1)
CHECK(inner_count == 1)
CHECK(cleaned_count == 0)
local output2 = output()
CHECK(output2() == "0")
input(1)
CHECK(outer_count == 1)
CHECK(inner_count == 2)
CHECK(cleaned_count == 1)
local output3 = output()
CHECK(output2() == "1")
CHECK(output3() == "1")
CHECK(output2 == output3)
destroy()
CHECK(cleaned_count == 2)
end end
end)) end))
@ -1765,48 +1755,6 @@ TEST("read()", wrap_root(function()
end)) end))
TEST("nested effects cases", function() TEST("nested effects cases", function()
-- local vide = require "src/init"
-- local source = vide.source
-- local effect = vide.effect
-- local untrack = vide.untrack
-- local cleanup = vide.cleanup
-- local root = vide.root
-- local ran = 0
-- local cleaned = 0
-- local function Count()
-- local count = source(0)
-- effect(function()
-- count()
-- ran += 1
-- cleanup(function() cleaned += 1 end)
-- end)
-- return nil
-- end
-- local function App(destroy)
-- local name = source "a"
-- effect(function()
-- name()
-- untrack(Count)
-- end)
-- CHECK(ran == 1)
-- CHECK(cleaned == 0)
-- name "b"
-- CHECK(ran == 2)
-- CHECK(cleaned == 1)
-- print(cleaned)
-- end
-- root(App)
local vide = require "src/init" local vide = require "src/init"
local source = vide.source local source = vide.source
local effect = vide.effect local effect = vide.effect
@ -1839,13 +1787,19 @@ TEST("nested effects cases", function()
CHECK(ran == 1) CHECK(ran == 1)
CHECK(cleaned == 0) CHECK(cleaned == 0)
name "b"
CHECK(ran == 2)
CHECK(cleaned == 1)
destroy()
CHECK(ran == 2)
CHECK(cleaned == 2)
end end
local ok = pcall(function() root(App)
root(App)
end)
CHECK(not ok)
end) end)
vide.strict = true vide.strict = true