diff --git a/CHANGELOG.md b/CHANGELOG.md index 2127a86..c42c5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Changed + +- Reactive scopes created within reactive scopes are now destroyed on rerun. + --- ## [0.1.0] - 2023-09-20 diff --git a/src/graph.luau b/src/graph.luau index de6f7e1..6917462 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -10,10 +10,14 @@ export type StartNode = { export type Node = { cache: T, - effect: ((T) -> T) | "owner" | "untracked", + effect: ((T) -> T) | false, cleanups: { () -> () } | false, - parents: { owner: StartNode?, [number]: StartNode }, - [number]: Node + + owned: { Node } | false, + owner: Node | false, + + parents: { StartNode }, + [number]: Node -- children } -- reactive scope stack @@ -50,7 +54,7 @@ local function get_owning_scope(): Node if not scope then 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`) - 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") end return scope @@ -62,8 +66,12 @@ local function add_child(parent: StartNode, child: Node) end local function set_owner(node: Node, owner: Node) - node.parents.owner = owner - table.insert(owner, node) + node.owner = owner + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end end local function open_scope(node: Node) @@ -96,18 +104,29 @@ local function run_cleanups(node: Node) end end +local function find_and_swap_pop(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(parent: StartNode, child: Node) - local idx = table.find(parent, child) - assert(idx, "child not found") - local n = #parent - parent[idx] = parent[n] - parent[n] = nil + find_and_swap_pop(parent, child) +end + +local function remove_owner(node: Node) + local owner = node.owner :: Node + if node.owner and owner.owned then + find_and_swap_pop(owner.owned, node) + end end local function unparent(node: Node) local parents = node.parents - for i, parent in ipairs(parents) do + for i, parent in next, parents do remove_child(parent, node) parents[i] = nil end @@ -116,15 +135,21 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) + remove_owner(node) - if node.parents.owner then - remove_child(node.parents.owner, node) - node.parents.owner = nil + if node.owned then + local owned = node.owned + while owned[1] do destroy(owned[1]) end end - while node[1] do destroy(node[1]) end end +local function destroy_owned(node: Node) + 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 } local function evaluate_node(node: Node) @@ -132,6 +157,8 @@ local function evaluate_node(node: Node) if flags.strict then run_cleanups(node) + destroy_owned(node) + open_scope(node) local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value) @@ -141,7 +168,9 @@ local function evaluate_node(node: Node) if not ok then throw(err :: string) end end - run_cleanups(node) -- todo: move in scope? + run_cleanups(node) + destroy_owned(node) + open_scope(node) local ok, new_value = pcall(node.effect :: (T) -> T, cur_value) @@ -204,11 +233,15 @@ local function track(node: StartNode) end end -local function create_node(value: T, effect: "owner" | (T) -> T): Node +local function create_node(value: T, effect: false | (T) -> T): Node return { cache = value, effect = effect, cleanups = false, + + owner = false, + owned = false, + parents = {}, } end diff --git a/src/maps.luau b/src/maps.luau index d18357c..1034e9a 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -30,7 +30,7 @@ end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } local owner = get_owning_scope() - local subowner = create_node(false, "owner") + local subowner = create_node(false, false) set_owner(subowner, owner) local input_cache = {} :: Map @@ -67,7 +67,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, if cv ~= v then 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 local node = create_start_node(v) @@ -125,7 +125,7 @@ end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } local owner = get_owning_scope() - local subowner = create_node(false, "owner") + local subowner = create_node(false, false) set_owner(subowner, owner) local cur_input_cache_up = {} :: Map @@ -156,7 +156,7 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] 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 local node = create_start_node(i) diff --git a/src/root.luau b/src/root.luau index 5f79bd7..50a1c1c 100644 --- a/src/root.luau +++ b/src/root.luau @@ -11,7 +11,7 @@ local destroy = graph.destroy local refs = {} local function root(fn: (destroy: () -> ()) -> T...): T... - local node = create_node(false, "owner") + local node = create_node(false, false) refs[node] = true -- prevent gc of root node diff --git a/src/switch.luau b/src/switch.luau index e3efbd6..421d583 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -38,7 +38,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () throw("map must map a value to a function") end - local new_scope = create_node(false, "owner") + local new_scope = create_node(false, false) last_scope = new_scope :: Node set_owner(new_scope, owner) diff --git a/src/untrack.luau b/src/untrack.luau index 32246d2..230674d 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -13,7 +13,7 @@ local function untrack(source: () -> T): T -- sources are only tracked if the node in scope has an effect local effect = scope.effect - scope.effect = "untracked" + scope.effect = false local ok, result = pcall(source) diff --git a/test/tests.luau b/test/tests.luau index 74a2e3e..08c3cff 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -51,7 +51,7 @@ TEST("graph", function() end local function scope() - return create_node(false, "owner") + return create_node(false, false) end local function cleanup(fn: () -> ()) @@ -257,10 +257,10 @@ TEST("graph", function() do local c = get_children(root) - CHECK(#c == 3) - CHECK(table.find(c, items_updated)) - CHECK(table.find(c, scope1 :: Node)) - CHECK(table.find(c, scope2 :: Node)) + CHECK(#c == 0) + -- CHECK(table.find(c, items_updated)) + -- CHECK(table.find(c, scope1 :: Node)) + -- CHECK(table.find(c, scope2 :: Node)) end do @@ -272,19 +272,19 @@ TEST("graph", function() do local c = get_children(scope1) - CHECK(#c == 1) - CHECK(table.find(c, bind1)) + CHECK(#c == 0) + --CHECK(table.find(c, bind1)) end do local c = get_children(scope2) - CHECK(#c == 1) - CHECK(table.find(c, bind2)) + CHECK(#c == 0) + --CHECK(table.find(c, bind2)) end -- destroy - CHECK(table.find(get_children(root), scope1 :: Node)) + --CHECK(table.find(get_children(root), scope1 :: Node)) destroy(scope1) CHECK(cleaned.scope1) @@ -293,7 +293,7 @@ TEST("graph", function() bind1 = NIL bind2 = NIL gc() - CHECK(#get_children(root) == 2) + CHECK(#get_children(root) == 0) CHECK(#get_children(selected) == 1) end @@ -1519,7 +1519,10 @@ end)) TEST("untrack()", wrap_root(function() local source = vide.source local effect = vide.effect + local derive = vide.derive local untrack = vide.untrack + local cleanup = vide.cleanup + local root = vide.root do CASE "does not register dependency" local a = source(0) @@ -1567,69 +1570,56 @@ TEST("untrack()", wrap_root(function() CHECK(count == 2) end - -- do CASE "outer scope" - -- local outer_count = 0 - -- local inner_count = 0 - -- local cleaned_count = 0 + do CASE "outer scope" + local outer_count = 0 + local inner_count = 0 + local cleaned_count = 0 - -- local input = source(0) + local input = source(0) - -- local output, destroy = root(function(destroy) - -- local output = derive(function() - -- outer_count += 1 + local output, destroy = root(function(destroy) + local output = derive(function() + outer_count += 1 - -- return untrack(function() - -- return derive(function() - -- inner_count += 1 + return untrack(function() + return derive(function() + inner_count += 1 - -- cleanup(function() - -- cleaned_count += 1 - -- end) + cleanup(function() + cleaned_count += 1 + end) - -- return tostring(input()) - -- 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 + return tostring(input()) + end) end) end) + + return output, destroy 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)) @@ -1765,48 +1755,6 @@ TEST("read()", wrap_root(function() end)) 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 source = vide.source local effect = vide.effect @@ -1839,13 +1787,19 @@ TEST("nested effects cases", function() CHECK(ran == 1) CHECK(cleaned == 0) + + name "b" + + CHECK(ran == 2) + CHECK(cleaned == 1) + + destroy() + + CHECK(ran == 2) + CHECK(cleaned == 2) end - local ok = pcall(function() - root(App) - end) - - CHECK(not ok) + root(App) end) vide.strict = true