This commit is contained in:
aaron 2023-09-06 23:01:53 +01:00
parent fe3af737be
commit dafdc0739b
10 changed files with 249 additions and 550 deletions

View file

@ -6,9 +6,9 @@ local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local create_and_open_scope = graph.create_and_open_scope
local init_scope = graph.init_scope
local close_scope = graph.close_scope
local set_child = graph.set_child
local add_child = graph.add_child
local capture = graph.capture
--[[
@ -29,14 +29,7 @@ todo: investigate behavior in case B is parented to A, and A has no parent or re
]]
-- holds parented instance proxies in memory
local hold: { Instance? } = {}
-- weakly references instances with properties bound
local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any
-- unique binding id
local bind_count = 0
-- todo: replace with throw's method
local root do
@ -74,45 +67,21 @@ function bind(instance: Instance, property: string, setter: (Instance) -> ())
end
end
local node = create(false)
local binding = create(instance)
create_and_open_scope(node)
init_scope(binding)
-- run setter to capture any nodes being depended on
local nodes = (capture(setter :: () -> unknown, instance))
close_scope()
-- get binding id
bind_count += 1
local bind_id = bind_count
node.effect = function()
local instance = weak[bind_id]
if instance == nil then return end
setter(weak[bind_id] :: Instance)
end
binding.effect = setter
-- register the setter as a side-effect of each node
for _, n in next, nodes do
set_child(n, node)
for _, node in next, nodes do
add_child(node, binding)
end
-- store reference of instance proxy without preventing gc
weak[bind_id] = instance
local function ref()
local _ = node -- prevent gc of node being depended on
local instance = weak[bind_id] :: Instance
-- keep proxy in memory if instance is still parented
hold[bind_id] = instance.Parent and instance or nil
end
ref()
instance:GetPropertyChangedSignal("Parent"):Connect(ref)
end
local function bind_property(instance: Instance, property: string, fn: () -> unknown)

View file

@ -13,130 +13,3 @@ local function cleanup(fn: () -> ())
end
return cleanup
--[[
Cleanups associate a callback with an arbitrary value with an unknown lifetime.
Anytime a new callback is registered with a value that already has one registered,
the registered callback is ran and then replaced with the new one.
When the value is eventually garbage collected, Vide checks for callbacks
without an associated value, which it will then run and clear, recycling its
cleanup id.
By default the arbitrary value is the function object that calls `cleanup()`.
There are exceptions such as with `indexes()` and `values()` where the
arbitrary value is manually set to be the new source created instead of the
caller, as the same caller can be used to create multiple new objects.
todo: remove need for ref to id maps?
]]
--[[
-- maps a ref to cleanup id
local ref_to_id = {} :: { [string]: number }
-- maps a cleanup id to a ref
local id_to_ref = {} :: { [number]: string }
-- array of all cleanup callbacks
local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense
-- weak array of all cleanup lifetimes
local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse
setmetatable(cleanup_lifetime :: any, { __mode = "v" })
-- detects in strict mode when multiple cleanups are registered in the same scope
local debug_caller_to_line = {} :: { [() -> ()]: number }
setmetatable(debug_caller_to_line, { __mode = "k" })
-- when active, cleanup callbacks are not automatically registered but are
-- added to an array for manual registering internally
local manual_mode = {
caller = false :: false | () -> (),
callbacks = {} :: { () -> () }
}
-- todo: rare case where mem address is reused by another function
-- does this case handle itself?
-- registers a callback with the given lifetime using the given ref
local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
local id = ref_to_id[ref]
if id then -- invoke previously registered callback then register new one
cleanup_callbacks[id]()
cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil
else -- no previously registered callback, add and register new one
id = #cleanup_callbacks + 1
ref_to_id[ref] = id
id_to_ref[id :: any] = ref -- todo
cleanup_lifetime[id :: any] = lifetime -- todo
end
cleanup_callbacks[id] = callback
end
-- registers a callback with its caller as the lifetime, and caller address as the ref
local function cleanup(callback: () -> ())
local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup`
if flags.strict then
local line = debug.info(2, "l")
local cur_line = debug_caller_to_line[lifetime]
if cur_line and cur_line ~= line then
throw "only one cleanup call is allowed per function scope"
end
debug_caller_to_line[lifetime] = line
end
if manual_mode.caller == lifetime then
table.insert(manual_mode.callbacks, callback)
else
local ref = tostring(lifetime)
cleanup_ref(ref, lifetime, callback)
end
end
local function clean_garbage()
for id = #cleanup_callbacks, 1, -1 do
if cleanup_lifetime[id] == nil then -- lifetime was garbage collected
local callback = cleanup_callbacks[id]
do -- swap and pop
local max_id = #cleanup_callbacks
cleanup_callbacks[id] = cleanup_callbacks[max_id]
cleanup_callbacks[max_id] = nil
cleanup_lifetime[id] = cleanup_lifetime[max_id]
cleanup_lifetime[max_id] = nil
local ref = id_to_ref[id]
local max_ref = id_to_ref[max_id]
id_to_ref[id] = max_ref
id_to_ref[max_id] = nil
ref_to_id[max_ref] = id
ref_to_id[ref] = nil
end
local ok, err: string? = pcall(callback)
if not ok then warn(`error occured during cleanup: {err}`) end
end
end
end
local manual_cleanup_mode = function(caller: () -> ()?)
if caller == nil then
local clone = table.clone(manual_mode.callbacks)
manual_mode.caller = false
table.clear(manual_mode.callbacks)
return clone
else
manual_mode.caller = caller
end
return manual_mode.callbacks
end :: ( (caller: (...any) -> ()) -> () ) & ( (nil) -> { () -> () } )
return function() return cleanup, clean_garbage, manual_cleanup_mode, cleanup_ref end
]]

View file

@ -2,20 +2,38 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
local create = graph.create
local capture_and_link = graph.capture_and_link
local create_and_open_scope = graph.create_and_open_scope
local capture = graph.capture
local add_child = graph.add_child
local set_effect = graph.set_effect
local update = graph.update
local track = graph.track
local init_scope = graph.init_scope
local close_scope = graph.close_scope
local function derive<T>(fn: () -> T): () -> T
local node, read_node_value = create((false :: any) :: T)
local node = create((false :: any) :: T)
create_and_open_scope(node)
init_scope(node)
node.cache = capture_and_link(node, fn)
local nodes, value = capture(fn)
close_scope()
return read_node_value
for _, parent in next, nodes do
add_child(parent, node)
end
set_effect(node, function()
node.cache = fn()
update(node)
end)
node.cache = value
return function()
track(node)
return node.cache
end
end
return derive

View file

@ -6,7 +6,8 @@ local on_gc = require(script.Parent.on_gc)()
export type Node<T> = {
cache: T,
effect: () -> (),
effect: (unknown) -> (),
parents: { Node<T> } | false,
children: { Node<T> } | false, -- weak values
cleanups: { () -> () } | false
}
@ -98,68 +99,80 @@ local function set_effect<T>(node: Node<unknown>, fn: () -> ())
end
local function run_effect(node: Node<unknown>)
node.effect()
end
-- retrieves a node's cached value
-- add self to refs if ref capture flag is enabled
local function get<T>(node: Node<T>): T
if reff then table.insert(refs, node) end
return node.cache
node.effect(node.cache)
end
-- links two nodes as parent-child
local function set_child(parent: Node<unknown>, child: Node<unknown>)
local function add_child(parent: Node<unknown>, child: Node<unknown>)
if parent.children then
table.insert(parent.children, child)
else
parent.children = { child }
setmetatable(parent.children :: any, WEAK_VALUES)
setmetatable(parent.children :: any, {})
end
end
local function create_and_open_scope(node: Node<unknown>)
local parent = scopes[scopes.n]
if parent then
set_child(parent, node)
node.effect = function()
return parent
local function add_children(parent: Node<unknown>, children: { Node<unknown> })
if parent.children then
for _, child in next, children do
table.insert(parent.children, child)
end
else
node.cleanups = {}
local cleanups = node.cleanups :: { () -> () }
on_gc(node, function()
run_cleanups({ cleanups = cleanups })
end)
parent.children = table.clone(children)
end
end
local function add_parent(child: Node<unknown>, parent: Node<unknown>)
child.parents = { parent }
end
local function rec(node: { effect: any, children: { Node<unknown> }, cleanups: { () -> () }})
run_cleanups(node)
node.effect = function() assert(false) end
if node.children then
for _, child in node.children do
rec(child)
end
end
end
local function destroy(node: Node<unknown>)
if node.parents then
for _, parent in node.parents do
parent.children[table.find(parent.children, node)] = nil -- todo: can iter invalidation occur here?
end
end
rec(node)
end
local function init_scope(node: Node<unknown>)
local parent = scopes[scopes.n]
if parent then
add_child(parent, node)
add_parent(node, parent)
end
open_scope(node)
end
-- runs node effects, recalculates descendants and runs descendant effects
local function update(node: Node<unknown>)
open_scope(node)
run_cleanups(node)
run_effect(node)
close_scope()
if node.children then
for _, child in node.children do
open_scope(child)
run_cleanups(child)
run_effect(child)
update(child)
close_scope()
end
end
end
-- sets a node's cached value and updates all descendants
local function set<T>(node: Node<T>, value: T)
node.cache = value
update(node)
end
-- links two nodes as parent-child with a function to compute a new value for child
local function link<T>(parent: Node<unknown>, child: Node<T>, derive: () -> T)
child.effect = function()
child.cache = derive()
end
set_child(parent, child)
add_child(parent, child)
end
-- detect what nodes were referenced in the given callback and returns them in an array
@ -192,39 +205,43 @@ local function capture_and_link<T>(child: Node<T>, derive: () -> T): T
child.cache = derive()
end
for _, parent: Node<unknown> in next, nodes do
set_child(parent, child)
add_child(parent, child)
end
return value :: T
end
local function create<T>(value: T): (Node<T>, () -> T)
local function track(node: Node<unknown>)
if reff then table.insert(refs, node) end
end
local function create<T>(value: T): Node<T>
local node = {
cache = value,
effect = function() end,
parents = false :: false,
children = false :: false,
cleanups = false :: false
}
local function read_node_value()
return get(node)
end
return node, read_node_value
return node
end
return table.freeze {
create_and_open_scope = create_and_open_scope,
init_scope = init_scope,
open_scope = open_scope,
close_scope = close_scope,
get_scope = get_scope,
add_cleanup = add_cleanup,
destroy = destroy,
run_cleanups = run_cleanups,
set_effect = set_effect,
get = get,
set = set,
track = track,
update = update,
link = link,
set_child = set_child,
add_parent = add_parent,
add_child = add_child,
add_children = add_children,
capture = capture,
capture_and_link = capture_and_link,
create = create :: (<T>(value: T) -> (Node<T>, () -> T)) & (<T>() -> (Node<T>, () -> T)),

View file

@ -11,11 +11,12 @@ local set = graph.set
local capture = graph.capture
local run_cleanups = graph.run_cleanups
local set_child = graph.set_child
local create_and_open_scope = graph.create_and_open_scope
local open_new_scope = graph.open_new_scope
local get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local link = graph.link
local destroy_tree = graph.destroy_tree
type Map<K, V> = { [K]: V }
@ -48,7 +49,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
-- remove queued values
for _, i in next, remove_queue do
run_cleanups(scopes[i])
destroy_tree(scopes[i])
input_cache[i] = nil
@ -67,7 +68,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
if cv == nil then
local scope = create(false)
create_and_open_scope(scope)
open_new_scope(scope)
local node, get_value = create(v)
input_nodes[i] = node
@ -94,7 +95,10 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
local output, read_output_value = create(nil :: any)
local scope = create(false)
local function derive()
local _ = scope
return recompute(input())
end
@ -106,13 +110,11 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
output.cache = recompute(value)
cleanup_ref(tostring(output), output, function()
for _, callbacks in next, cleanups do
for _, callback in next, callbacks do
callback() -- todo: pcall
end
end
end)
local scope_parent = get_scope()
set_child(scope_parent, scope)
return read_output_value
end

View file

@ -2,22 +2,35 @@ if not game then script = require "test/relative-string" end
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
local on_gc = require(script.Parent.on_gc)()
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local create_and_open_scope = graph.create_and_open_scope
local init_scope = graph.init_scope
local close_scope = graph.close_scope
local get_scope = graph.get_scope
local add_cleanup = graph.add_cleanup
local destroy = graph.destroy
local refs = {} :: { [Node<unknown>]: unknown }
setmetatable(refs :: any, { __mode = "v" })
local function root<T>(fn: () -> T): T
assert(not get_scope())
local node = create(nil) -- todo: lifetime with return vaue from fn
create_and_open_scope(node)
init_scope(node)
local v = fn()
close_scope()
refs[node] = v
on_gc(v, function()
destroy(node)
end)
return v
end

View file

@ -3,22 +3,29 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local track = graph.track
local update = graph.update
export type Source<T> = (() -> T) & ((T) -> T)
local function source<T>(value: T): Source<T>
local node, read_node_value = create(value :: T)
local function source<T>(initial_value: T): Source<T>
local node = create(initial_value)
return function(...): T
if select("#", ...) == 0 then return read_node_value() end -- check if any args were given
if select("#", ...) == 0 then -- no args were given
track(node)
return node.cache
end
local v = ... :: T
if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then return v end
if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then
return v
end
set(node, v)
node.cache = v
update(node)
return v
end
end
return source :: (<T>(value: T) -> Source<T>) & (<T>() -> Source<T>)
return source :: (<T>(initial_value: T) -> Source<T>) & (<T>() -> Source<T>)

View file

@ -2,17 +2,17 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
local create = graph.create
local set_child = graph.set_child
local add_parent = graph.add_parent
local add_child = graph.add_child
local capture = graph.capture
local create_and_open_scope = graph.create_and_open_scope
local init_scope = graph.init_scope
local close_scope = graph.close_scope
local ref = {}
local destroy = graph.destroy
local function watch(effect: () -> ()): () -> ()
local node = create(nil)
local node = create(false)
create_and_open_scope(node)
init_scope(node)
local nodes = capture(effect :: () -> nil)
@ -20,17 +20,14 @@ local function watch(effect: () -> ()): () -> ()
node.effect = effect
-- register effect with permanent lifetime
for _, parent in next, nodes do
set_child(parent, node)
add_parent(node, parent)
add_child(parent, node)
end
ref[node] = true -- prevent gc of node
local function unwatch()
-- unregister effect from all nodes
node.effect = function() end
ref[node] = nil
destroy(node)
end
return unwatch