if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) export type StartNode = { cache: T, [number]: Node } export type Node = { cache: T, effect: ((T) -> T) | false, cleanups: { () -> () } | false, owned: { Node } | false, owner: Node | false, parents: { StartNode }, [number]: Node -- children } -- reactive scope stack local scopes = { n = 0 } :: { [number]: Node, n: number } local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) local thread = coroutine.create(pcall) local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) assert(resume_ok) if coroutine.status(thread) ~= "dead" then return false, "attempt to yield in reactive scope" end return run_ok, result end local function get_scope(): Node? return scopes[scopes.n] end local function get_owning_scope(): Node local scope = get_scope() 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 then throw("cannot create new reactive scope inside of a tracking scope") -- todo: allow this? end return scope end local function add_child(parent: StartNode, child: Node) table.insert(parent, child) table.insert(child.parents, parent) end local function set_owner(node: Node, 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) local n = scopes.n + 1 scopes.n = n scopes[n] = node end local function close_scope() local n = scopes.n scopes.n = n - 1 scopes[n] = nil end local function add_cleanup(node: Node, cleanup: () -> ()) if node.cleanups then table.insert(node.cleanups, cleanup) else node.cleanups = { cleanup } end end local function run_cleanups(node: Node) if node.cleanups then for _, fn in next, node.cleanups do local ok, err: string? = pcall(fn) if not ok then throw(`cleanup error: {err}`) end end table.clear(node.cleanups) end end local function find_and_swap_pop(t: { T }, v: T) local idx = table.find(t, v) :: number local n = #t t[idx] = t[n] t[n] = nil end local function unparent(node: Node) local parents = node.parents for i, parent in next, parents do find_and_swap_pop(parent, node) parents[i] = nil end end local function destroy(node: Node) run_cleanups(node) unparent(node) if node.owner then find_and_swap_pop(node.owner.owned :: { Node }, node) node.owner = false end if node.owned then local owned = node.owned while owned[1] do destroy(owned[1]) end end end local function destroy_owned(node: Node) if node.owned then local owned = node.owned while owned[1] do destroy(owned[1]) end end end local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) local cur_value = node.cache if flags.strict then run_cleanups(node) destroy_owned(node) open_scope(node) local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) close_scope() if not ok then throw(new_value :: string) end node.cache = new_value :: T end run_cleanups(node) destroy_owned(node) open_scope(node) local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) close_scope() if not ok then table.clear(update_queue) update_queue.n = 0 throw(`side-effect error from source update\n{new_value}`) end node.cache = new_value return cur_value ~= new_value end local function queue_children(node: StartNode) local i = update_queue.n while node[1] do i += 1 update_queue[i] = node[1] unparent(node[1]) end update_queue.n = i end local function flush_update_queue() -- todo: test with recursive batch sets local n0 = 0 local i = n0 + 1 while i <= update_queue.n do local node = update_queue[i] --assert(node.effect) if node.owner and evaluate_node(node) then queue_children(node) end update_queue[i] = false :: any i += 1 end update_queue.n = n0 end local function update(root: StartNode) local n0 = update_queue.n queue_children(root) if flags.batch then return end local i = n0 + 1 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(node) end update_queue[i] = false :: any -- false instead of nil to avoid sparse i += 1 end update_queue.n = n0 end local function track(node: StartNode) local scope = get_scope() if scope and scope.effect then -- do not track nodes with no effect add_child(node, scope) end end local function create_node(value: T, effect: false | (T) -> T): Node return { cache = value, effect = effect, cleanups = false, owner = false, owned = false, parents = {}, } end local function create_start_node(value: T): StartNode return { cache = value } end local function get_children(node: Node): { Node } return { unpack(node) } :: { Node } end return table.freeze { open_scope = open_scope, close_scope = close_scope, evaluate_node = evaluate_node, get_scope = get_scope, get_owning_scope = get_owning_scope, add_cleanup = add_cleanup, set_owner = set_owner, destroy = destroy, run_cleanups = run_cleanups, track = track, update = update, add_child = add_child, create_node = create_node, create_start_node = create_start_node, get_children = get_children, flush_update_queue = flush_update_queue, scopes = scopes }