Merge reactive scope refactor

This commit is contained in:
aaron 2023-09-15 12:54:42 +01:00
parent 0e439f084f
commit efc4798ddb
48 changed files with 2750 additions and 1949 deletions

View file

@ -3,7 +3,7 @@ type Action = {
callback: (Instance) -> ()
}
local ActionMT = {}
local ActionMT = table.freeze {}
local function is_action(v: any)
return getmetatable(v) == ActionMT
@ -17,7 +17,7 @@ local function action(callback: (Instance) -> (), priority: number?): Action
setmetatable(t :: any, ActionMT)
return t
return table.freeze(t)
end
return function()

View file

@ -10,11 +10,16 @@ local _, is_action = require(script.Parent.action)()
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
type Array<V> = { V }
type Map<K, V> = { [K]: V }
-- buffer of event -> callback to connect after properties are set
local event_buffer: { [string]: () -> () } = {}
local event_buffer = {} :: Map<string, () -> ()>
-- buffer of priority -> callback to run after events are connected
local action_buffers = {} :: { { (Instance) -> () } }
local action_buffers = {} :: Map<number, Array<(Instance) -> ()>>
-- lazily create buffers on nil index
setmetatable(action_buffers :: any, {
__index = function(_, i: number)
action_buffers[i] = {}
@ -22,8 +27,9 @@ setmetatable(action_buffers :: any, {
end
})
-- cache used in strict mode to detect duplicate property sets at same nesting levels
local nested_debug_cache: { [number]: { [string]: true } } = {}
-- cache in strict mode to detect duplicate property set at same nesting level
local nested_debug_cache = {} :: Map<number, Map<string, true>>
setmetatable(nested_debug_cache :: any, {
__index = function(_, i: number)
nested_debug_cache[i] = {}
@ -31,28 +37,30 @@ setmetatable(nested_debug_cache :: any, {
end
})
-- a stack used in place of a recursive function to process nesting layers one at a time
-- enforces the behavior of deeper-nested properties taking precedence of lesser-nested ones
-- each nested table occupies two indexes, reference to table itself and the depth number
-- e.g. props = { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
-- use stack instead of recursive function to process nested layers one at time
-- deeper-nested properties take precedence over shallower-nested ones
-- each nested layer occupies two indexes: 1. table ref 2. nested depth
-- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 }
local nested_stack = {} :: { {} | number }
-- todo: solution without manual updating of this table
-- map of datatype names to class default constructor for aggregate initialization
-- map of datatype names to class default constructor for aggregate init
local aggregates = {}
for i, v in next, {
Vector2 = Vector2,
UDim2 = UDim2,
CFrame = CFrame,
Color3 = Color3,
UDim = UDim,
Rect = Rect,
Color3 = Color3
UDim2 = UDim2,
Vector2 = Vector2,
Vector3 = Vector3,
Rect = Rect
} do
aggregates[i] = v.new
end
-- processes a potentially nested table of values to assign to an instance
local function process_nested(instance: Instance, properties: { [unknown]: unknown })
local function process_props(instance: Instance, properties: Map<unknown, unknown>)
local strict = flags.strict
table.clear(nested_stack)
@ -73,27 +81,27 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno
if type(value) == "table" then -- attempt aggregate init
local ctor = aggregates[typeof((instance :: any)[property])]
if ctor == nil then
throw(`cannot aggregate construct type {typeof(value)} for property {property}`)
throw(`cannot aggregate type {typeof(value)} for property {property}`)
end
(instance :: any)[property] = ctor(unpack(value :: {}))
elseif type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> () -- add event to buffer
else
bind.property(instance, property, value :: () -> ()) -- bind source
bind.property(instance, property, value :: () -> ()) -- bind property
end
else
(instance :: any)[property] = value -- set property
end
elseif type(property) == "number" then
if type(value) == "function" then
bind.children(instance, value :: () -> { Instance }) -- bind children
bind.children(instance, value :: () -> Instance | Array<Instance>) -- bind children
elseif type(value) == "table" then
if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer
else
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
table.insert(nested_stack, value :: {})
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
end
else
(value :: Instance).Parent = instance -- parent child
@ -102,8 +110,8 @@ local function process_nested(instance: Instance, properties: { [unknown]: unkno
end
-- pop next nested table off stack
properties = table.remove(nested_stack) :: {}
depth = table.remove(nested_stack) :: number
properties = table.remove(nested_stack) :: {}
until not properties
end
@ -121,14 +129,14 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
end
-- process all properties for immediate setting or buffering
process_nested(instance, properties)
process_props(instance, properties)
-- connect buffered events
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
-- run buffered actions respecting their priorities
-- run buffered actions
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback(instance)
@ -138,7 +146,7 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
-- finally set parent if any
if parent then
if type(parent) == "function" then
error("cannot set parent to state")
bind.parent(instance, parent :: () -> Instance)
else
instance.Parent = parent :: Instance
end

View file

@ -1,152 +1,130 @@
if not game then script = require "test/relative-string" end
local warn = game and warn or print :: never
local throw = require(script.Parent.throw)
local trace = require(script.Parent.trace)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local set_effect = graph.set_effect
local capture = graph.capture
local create_node = graph.create_node
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
--[[
Roblox instances in Luau are referenced using a kind of userdata proxy,
this proxy can be garbage collected independently from the actual instance, even
if the instance is still parented. Since reactive bindings allow the garbage
collection of instances, this proxy can can garbage collected while the instance
is still parented, causing the binding to be lost and no longer update the
instance on changes.
Vide's solution to this is to hold the proxy in memory as long as the instance
is parented to the datamodel by using `GetPropertyChanged("Parent")` to add or
remove the proxy from a table whose sole purpose is to strongly reference
proxies.
todo: investigate behavior in case B is parented to A, and A has no parent or reference, and B has a binding.
]]
-- 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
local src = debug.info(1, "s")
root = string.sub(src, 1, #src - 5)
end
local function traceback(skips: number) -- ensures trace begins outside of any vide library file
local s = 1
repeat
s += 1
local path = debug.info(s, "s")
local found = not string.find(path, root)
if found then
skips -= 1
end
until found and skips < 0
return debug.traceback(nil, s)
end
function bind(instance: Instance, property: string, setter: (Instance) -> ())
function create_binding<T>(updater: (T) -> T, binding: T)
if flags.strict then
-- wrap setter in function with stack inspection for better error msgs
local fn = setter
local bind_trace = traceback(0)
setter = function(instance)
local ok, err: string? = xpcall(fn, function(err: string)
return err .. "\nsource updated at: " .. traceback(2)
end, instance)
if not ok then warn(`error occured updating {property}: {err}bound at: {bind_trace}`) end
-- track bind creation trace
local fn = updater
local bind_trace = debug.traceback(nil, trace()-1)
updater = function(...)
local ok, result = xpcall(fn, function(err: string)
return err
end, ...)
if not ok then
local btype =
if (binding :: any).property then (binding :: any).property
elseif (binding :: any).parent then "Parent"
else "children"
error(`PROPERTY BINDING ERROR: Property {btype}\n{result}\nBIND CREATION TRACE:\n{bind_trace}`, 0)
end
return result
end
end
-- run setter to capture any nodes being depended on
local nodes = (capture(setter :: () -> unknown, instance))
-- register the setter as a side-effect of each node
for _, node in next, nodes do
set_effect(node, setter, instance)
end
-- get binding id
bind_count += 1
local bind_id = bind_count
-- store reference of instance proxy without preventing gc
weak[bind_id] = instance
local owner = get_scope()
if not owner then
throw("cannot bind property in non-reactive scope")
end; assert(owner)
local node = create_node(binding, updater)
local function ref()
local _ = setter -- prevent gc of nodes being depended on
local instance = weak[bind_id] :: Instance
set_owner(node, owner)
evaluate_node(node)
end
-- keep proxy in memory if instance is still parented
hold[bind_id] = instance.Parent and instance or nil
type PropertyBinding = {
instance: Instance,
property: string,
source: () -> unknown
}
local function update_property(p: PropertyBinding)
(p.instance :: any)[p.property] = p.source()
return p
end
type ParentBinding = {
instance: Instance,
parent: () -> Instance
}
local function update_parent(p: ParentBinding)
p.instance.Parent = p.parent()
return p
end
type ChildrenBinding = {
instance: Instance,
cur_children_set: { [Instance]: true },
new_children_set: { [Instance]: true },
children: () -> Instance | { Instance }
}
local function update_children(p: ChildrenBinding)
local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update
local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update
local new_children = p.children() -- all (and only) children that should be parented after this update
if type(new_children) ~= "table" then
new_children = { new_children }
end
ref()
instance:GetPropertyChangedSignal("Parent"):Connect(ref)
end
local function bind_property(instance: Instance, property: string, fn: () -> unknown)
bind(instance, property, function(instance_weak: any)
instance_weak[property] = fn()
end)
end
local function bind_parent(instance: Instance, fn: () -> Instance?)
instance.Destroying:Connect(function()
instance = nil :: any -- allow gc when destroyed
end)
bind(instance, "Parent", function(instance)
local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = fn()
end)
end
local function bind_children(parent: Instance, fn: () -> { Instance })
local current_child_set: { [Instance]: true } = {} -- cache of all children parented before update
local new_child_set: { [Instance]: true } = {} -- cache of all children parented after update
bind(parent, "Children", function(parent_weak)
local new_childs = fn() -- all (and only) children that should be parented after this update
if new_childs and type(new_childs) ~= "table" then
throw(`Cannot parent instance of type { type(new_childs) } `)
end
if new_childs then
for _, child in next, new_childs do
new_child_set[child] = true -- record child set from this update
if not current_child_set[child] then
child.Parent = parent_weak -- if child wasn't already parented then parent it
else
current_child_set[child] = nil -- remove child from cache if it was already in cache
end
if new_children then
for _, child in next, new_children :: { Instance } do
new_child_set[child] = true -- record child set from this update
if not cur_children_set[child] then
child.Parent = p.instance -- if child wasn't already parented then parent it
else
cur_children_set[child] = nil -- remove child from cache if it was already in cache
end
end
end
for child in next, current_child_set do
child.Parent = nil -- unparent all children that weren't in the new children set
end
for child in next, cur_children_set do
child.Parent = nil -- unparent all children that weren't in the new children set
end
table.clear(current_child_set) -- clear cache, preserve capacity
current_child_set, new_child_set = new_child_set, current_child_set
end)
table.clear(cur_children_set) -- clear cache, preserve capacity
p.cur_children_set, p.new_children_set = new_child_set, cur_children_set
return p
end
return {
property = bind_property,
parent = bind_parent,
children = bind_children,
property = function(instance, property, source)
return create_binding(update_property, {
instance = instance,
property = property,
source = source
})
end,
parent = function(instance, parent)
return create_binding(update_parent, {
instance = instance,
parent = parent
})
end,
children = function(instance, children)
return create_binding(update_children, {
instance = instance,
cur_children_set = {},
new_children_set = {},
children = children
})
end
}

18
src/changed.luau Normal file
View file

@ -0,0 +1,18 @@
if not game then script = require "test/relative-string" end
local action = require(script.Parent.action)()
local cleanup = require(script.Parent.cleanup)
local function changed<T>(property: string, callback: (T) -> ())
return action(function(instance)
local con = instance:GetPropertyChangedSignal(property):Connect(function()
callback((instance :: any)[property])
end)
cleanup(function()
con:Disconnect()
end)
end)
end
return changed

View file

@ -1,129 +1,18 @@
if not game then script = require "test/relative-string" end
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local get_scope = graph.get_scope
local add_cleanup = graph.add_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`
local scope = get_scope()
if not scope then
throw("cannot cleanup in a non-reactive scope")
end; assert(scope)
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
add_cleanup(scope, callback)
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]
return cleanup
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

@ -7,11 +7,11 @@ local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply)
local memoize = require(script.Parent.memoize)
local function create_instance(class_name: string)
local ok, instance: Instance = pcall(Instance.new, class_name :: any)
if not ok then throw(`invalid class name, could not create instance of class { class_name }`) end
local function create_instance(class: string)
local ok, instance: Instance = pcall(Instance.new, class :: any)
if not ok then throw(`invalid class name, could not create instance of class { class }`) end
local default: { [string]: unknown }? = defaults[class_name]
local default: { [string]: unknown }? = defaults[class]
if default then
for i, v in next, default do
(instance :: any)[i] = v

View file

@ -1,10 +1,11 @@
local Enum = game and Enum or require "test/mock".Enum :: never
local Color3 = game and Color3 or require "test/mock".Color3 :: never
local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never
return {
Part = {
Material = Enum.Material.SmoothPlastic,
--Size = Vector3.new(1, 1, 1),
Size = Vector3.new(1, 1, 1),
Anchored = true
},

View file

@ -1,15 +1,28 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local create = graph.create
local capture_and_link = graph.capture_and_link
local create_node = graph.create_node
local set_owner = graph.set_owner
local track = graph.track
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local function derive<T>(fn: () -> T): () -> T
local node, read_node_value = create((false :: any) :: T)
local function derive<T>(source: () -> T): () -> T
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
node.cache = capture_and_link(node, fn)
local node = create_node(false :: any, source)
return read_node_value
set_owner(node, owner)
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
return derive

22
src/effect.luau Normal file
View file

@ -0,0 +1,22 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local create_node = graph.create_node
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local function effect<T>(callback: (T) -> T, initial_value: T)
local owner = get_scope()
if not owner then
throw("cannot create effect in non-reactive scope")
end; assert(owner)
local node = create_node(initial_value, callback)
set_owner(node, owner)
evaluate_node(node)
end
return effect :: (<T>(callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ())

View file

@ -3,25 +3,24 @@ if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
export type Node<T> = {
export type StartNode<T> = {
cache: T,
derive: () -> T,
effects: { [(unknown) -> ()]: unknown }, -- weak values
children: { Node<T> } | false -- weak values
[number]: Node<T>
}
-- flag used to detect when node reference capturing is active
local reff = false
-- array of all nodes referenced since above flag was set
local refs = {} :: { Node<unknown> }
export type Node<T> = {
cache: T,
effect: ((T) -> T) | false,
cleanups: { () -> () } | false,
parents: { owner: StartNode<T>?, [number]: StartNode<T> },
[number]: Node<T>
}
local WEAK_VALUES = { __mode = "v" }
local EVALUATION_ERR = "error while evaluating source:\n\n"
setmetatable(refs :: any, WEAK_VALUES)
-- reactive scope stack
local scopes = { n = 0 } :: { [number]: Node<any>, n: number }
-- runs a given callback in a context that Luau does not allow yielding in
local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
local check_for_yield: <T...>(fn: (T...) -> (), T...) -> (boolean, string?) do
local t = { __mode = "kv" }
setmetatable(t, t)
@ -32,149 +31,197 @@ local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
fn(unpack(args))
end
local ok, err = pcall(function()
local ok, err: string? = pcall(function()
local _ = -t
end)
if not ok then
if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then
throw(EVALUATION_ERR .. "cannot yield when deriving node in watcher")
else
throw(EVALUATION_ERR .. err)
end
end
return ok, if err == "attempt to yield across metamethod/C-call boundary"
or err == "thread is not yieldable" then "yield occured"
else err
end
end
--[[
Each node side-effect is registered with a corresponding weak key.
This makes the lifetime of the side-effect tied to the key's.
The main usecase of this is to tie a side-effect to an instance, while allowing
the instance to be garbage collected even when the node still exists.
The weak key is passed as an argument to its side-effect callback.
]]
local function set_effect<T>(node: Node<unknown>, fn: (T) -> (), key: T)
node.effects[fn :: () -> ()] = key
local function get_scope(): Node<unknown>?
return scopes[scopes.n]
end
local function run_effects(node: Node<unknown>)
if flags.strict then -- run effects twice if strict
for effect, key in next, node.effects do
effect(key)
effect(key)
end
local function add_child<T>(parent: StartNode<any>, child: Node<any>)
table.insert(parent, child)
table.insert(child.parents, parent)
end
local function set_owner(node: Node<any>, owner: Node<any>)
node.parents.owner = owner
table.insert(owner, node)
end
local function open_scope<T>(node: Node<T>)
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<T>(node: Node<T>, cleanup: () -> ())
if node.cleanups then
table.insert(node.cleanups, cleanup)
else
for effect, key in next, node.effects do
effect(key)
node.cleanups = { cleanup }
end
end
local function run_cleanups<T>(node: Node<T>)
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 remove_child<T>(parent: StartNode<T>, child: Node<T>)
local idx = table.find(parent, child)
assert(idx, "child not found")
local n = #parent
parent[idx] = parent[n]
parent[n] = nil
end
local function unparent<T>(node: Node<T>)
local parents = node.parents
for i, parent in ipairs(parents) do
remove_child(parent, node)
parents[i] = nil
end
end
local function destroy<T>(node: Node<T>)
run_cleanups(node)
unparent(node)
node.effect = false
if node.parents.owner then
remove_child(node.parents.owner, node)
node.parents.owner = nil
end
while node[1] do destroy(node[1]) end
end
local update_queue = {} :: { Node<any> }
local function evaluate_node<T>(node: Node<T>)
local cur_value = node.cache
if flags.strict then
run_cleanups(node)
open_scope(node)
local ok, err = check_for_yield(node.effect :: (T) -> T, cur_value)
close_scope()
if not ok then throw(err :: string) end
end
run_cleanups(node) -- todo: move in scope?
open_scope(node)
local ok, new_value = pcall(node.effect :: (T) -> T, cur_value)
close_scope()
if not ok then
table.clear(update_queue)
throw(`side-effect error from source update\n{new_value}`)
end
node.cache = new_value
return cur_value ~= new_value -- node has changed value
end
local function update_from<T>(node: StartNode<T>, n0: number)
if not node[1] then return end
local n = n0
-- unparent all children and queue for eval
do
local child = node[1]
while child do -- todo: case where child in owner context
unparent(child)
n += 1
update_queue[n] = child
child = node[1]
end
end
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
end
-- evaluate all queued children
for i = n0 + 1, n do
local child = update_queue[i]
if not child.effect then continue end
-- links two nodes as parent-child
local function set_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)
end
end
-- runs node effects, recalculates descendants and runs descendant effects
local function update(node: Node<unknown>)
run_effects(node)
if node.children then
local strict = flags.strict
for _, child in node.children do
if strict then check_for_yield(child.derive) end
child.cache = child.derive()
update(child)
if evaluate_node(child) then
update_from(child, n)
end
update_queue[i] = false :: any -- false instead of nil to avoid sparse
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)
local function update<T>(node: StartNode<T>)
update_from(node, 0)
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.derive = derive
set_child(parent, child)
end
-- detect what nodes were referenced in the given callback and returns them in an array
local function capture<T, U>(fn: (U?) -> T, arg: U?): ({ Node<unknown> }, T)
if reff then throw("recursive capture detected") end
if flags.strict then check_for_yield(fn, arg) end
table.clear(refs)
reff = true
local ok: boolean, result: T|string
if arg == nil then
ok, result = pcall(fn)
else
ok, result = pcall(fn, arg)
local function track<T>(node: StartNode<T>)
local scope = get_scope()
if scope and scope.effect then -- do not track nodes with no effect
add_child(node, scope)
end
reff = false
if not ok then throw(EVALUATION_ERR .. result :: string) end
return refs, result :: T
end
-- captures and links any detected nodes
local function capture_and_link<T>(child: Node<T>, fn: () -> T): T
local nodes, value = capture(fn, nil)
child.derive = fn
for _, parent: Node<unknown> in next, nodes do
set_child(parent, child)
end
return value :: T
end
local function create<T>(value: T): (Node<T>, () -> T)
local node = {
local function create_node<T>(value: T, effect: false | (T) -> T): Node<T>
return {
cache = value,
derive = function() return nil :: any end,
effects = setmetatable({}, WEAK_VALUES) :: any,
children = false :: false
effect = effect,
cleanups = false,
parents = {},
}
end
local function read_node_value()
return get(node)
end
local function create_start_node<T>(value: T): StartNode<T>
return { cache = value }
end
return node, read_node_value
local function get_children<T>(node: Node<T>): { Node<unknown> }
return { unpack(node) } :: { Node<any> }
end
return table.freeze {
set_effect = set_effect,
get = get,
set = set,
link = link,
capture = capture,
capture_and_link = capture_and_link,
create = create :: (<T>(value: T) -> (Node<T>, () -> T)) & (<T>() -> (Node<T>, () -> T)),
refs = refs
open_scope = open_scope,
close_scope = close_scope,
evaluate_node = evaluate_node,
get_scope = get_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,
scopes = scopes
}

View file

@ -5,16 +5,20 @@
if not game then script = require "test/relative-string" end
local root = require(script.root)
local mount = require(script.mount)
local create = require(script.create)
local apply = require(script.apply)
local source = require(script.source)
local watch = require(script.watch)
local cleanup, clean_garbage = require(script.cleanup)()
local effect = require(script.effect)
local cleanup = require(script.cleanup)
local untrack = require(script.untrack)
local derive = require(script.derive)
local switch = require(script.switch)
local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)()
local action = require(script.action)()
local changed = require(script.changed)
local throw = require(script.throw)
local flags = require(script.flags)
@ -28,13 +32,6 @@ local function step(dt: number)
update_springs(dt)
if game then
debug.profileend()
debug.profilebegin("VIDE GARBAGE CLEANUP")
end
clean_garbage()
if game then
debug.profileend()
debug.profileend()
@ -47,10 +44,13 @@ end)
local vide = {
-- core
root = root,
mount = mount,
create = create,
source = source,
watch = watch,
effect = effect,
derive = derive,
switch = switch,
indexes = indexes,
values = values,
@ -63,6 +63,7 @@ local vide = {
-- actions
action = action,
changed = changed,
-- flags
strict = (nil :: any) :: boolean,

View file

@ -1,16 +1,20 @@
if not game then script = require "test/relative-string" end
-- todo: more testing needed regarding `cleanup()` usage
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph)
local _, _, manual_cleanup_mode, cleanup_ref = require(script.Parent.cleanup)()
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local capture = graph.capture
local link = graph.link
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local create_start_node = graph.create_start_node
local set_owner = graph.set_owner
local track = graph.track
local update = graph.update
local get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local evaluate_node = graph.evaluate_node
local destroy = graph.destroy
type Map<K, V> = { [K]: V }
@ -23,17 +27,22 @@ local function check_primitives(t: {})
end
end
-- todo: optimize output array
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
local subowner = create_node(false, false)
set_owner(subowner, owner)
local input_cache = {} :: Map<K, VI>
local output_cache = {} :: Map<K, VO>
local input_nodes = {} :: Map<K, Node<VI>>
local input_nodes = {} :: Map<K, StartNode<VI>>
local remove_queue = {} :: { K }
local output_array = {} :: { VO }
local scopes = {} :: Map<K, Node<unknown>>
local cleanups = {} :: Map<K, { () -> () }>
local function recompute(data)
local function update_children(data)
-- queue removed values
for i in next, input_cache do
if data[i] == nil then
@ -43,41 +52,58 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
-- remove queued values
for _, i in next, remove_queue do
for _, callback in next, cleanups[i] do
callback() -- todo: pcall
end
destroy(scopes[i])
input_cache[i] = nil
output_cache[i] = nil
input_nodes[i] = nil
cleanups[i] = nil
scopes[i] = nil
end
table.clear(remove_queue)
open_scope(subowner)
-- process new or changed values
for i, v in next, data do
local cv = input_cache[i]
if cv ~= v then
if cv == nil then
manual_cleanup_mode(transform)
if cv == nil then -- create new scope and run transform
local scope = create_node(false, false)
scopes[i] = scope :: Node<any>
local node, get_value = create(v)
local node = create_start_node(v)
set_owner(scope, subowner)
open_scope(scope)
local ok, result = pcall(transform, function()
track(node)
return node.cache
end, i)
close_scope()
if not ok then
close_scope() -- subowner scope
error(result, 0)
end
input_nodes[i] = node
output_cache[i] = transform(get_value, i)
input_cache[i] = v
cleanups[i] = manual_cleanup_mode(nil)
else
set(input_nodes[i], v)
input_cache[i] = v
output_cache[i] = result
else -- update source
input_nodes[i].cache = v
update(input_nodes[i])
end
input_cache[i] = v
end
end
-- output elements
table.clear(output_array)
close_scope()
local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
@ -86,43 +112,34 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
return output_array
end
local output, read_output_value = create(nil :: any)
local function derive()
return recompute(input())
end
local nodes, value = capture(input)
for _, node in next, nodes do
link(node, output, derive)
end
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
local node = create_node(false :: any, function()
return update_children(input())
end)
return read_output_value
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
-- todo: optimize output array
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
local subowner = create_node(false, false)
set_owner(subowner, owner)
local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K>
local output_cache = {} :: Map<VI, VO>
local input_nodes = {} :: Map<VI, Node<K>>
local output_array = {} :: { VO }
local input_nodes = {} :: Map<VI, StartNode<K>>
local scopes = {} :: Map<VI, Node<unknown>>
local cleanups = {} :: Map<VI, { () -> () }>
local function recompute(data: Map<K, VI>)
local function update_children(data: Map<K, VI>)
local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up
if flags.strict then
@ -134,6 +151,8 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
cache[v] = true
end
end
open_scope(subowner)
-- process data
for i, v in next, data do
@ -141,71 +160,73 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local cv = cur_input_cache[v]
if cv == nil then
manual_cleanup_mode(transform)
if cv == nil then -- create new scope and run transform
local scope = create_node(false, false)
scopes[v] = scope :: Node<any>
local node, get_value = create(i)
input_nodes[v] = node
output_cache[v] = transform(v, get_value)
local node = create_start_node(i)
set_owner(scope, subowner)
open_scope(scope)
local ok, result = pcall(transform, v, function()
track(node)
return node.cache
end)
close_scope()
cleanups[v] = manual_cleanup_mode(nil)
else
if cv ~= i then
set(input_nodes[v], i)
if not ok then
close_scope() -- subowner scope
error(result, 0)
end
input_nodes[v] = node
output_cache[v] = result
else -- update source
if cv ~= i then
input_nodes[v].cache = i
update(input_nodes[v])
end
cur_input_cache[v] = nil
end
end
close_scope()
-- remove old values
for v in next, cur_input_cache do
for _, callback in next, cleanups[v] do
callback() -- todo: pcall
end
destroy(scopes[v])
output_cache[v] = nil
input_nodes[v] = nil
cleanups[v] = nil
scopes[v] = nil
end
-- update buffer cache
table.clear(cur_input_cache)
cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache
-- output elements
table.clear(output_array)
local output_array = table.create(#scopes)
for _, v in next, output_cache do
table.insert(output_array, v)
end
check_primitives(output_array)
return output_array
end
local output, read_output_value = create(nil :: any)
local function derive()
return recompute(input())
end
local nodes, value = capture(input)
for _, node in next, nodes do
link(node, output, derive)
end
check_primitives(output_array)
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
local node = create_node(false :: any, function()
return update_children(input())
end)
return read_output_value
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
return function() return indexes, values end

14
src/mount.luau Normal file
View file

@ -0,0 +1,14 @@
if not game then script = require "test/relative-string" end
local root = require(script.Parent.root)
local apply = require(script.Parent.apply)
local function mount<T>(component: () -> T, target: Instance?): () -> ()
return root(function(destroy)
local result = component()
if target then apply(target, { result }) end
return destroy
end)
end
return mount :: (<T>(component: () -> T, target: Instance) -> () -> ()) & ((component: () -> ()) -> () -> ())

38
src/root.luau Normal file
View file

@ -0,0 +1,38 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create_node = graph.create_node
local open_scope = graph.open_scope
local close_scope = graph.close_scope
local destroy = graph.destroy
local refs = {}
local function root<T...>(fn: (destroy: () -> ()) -> T...): T...
local node = create_node(false, false)
refs[node] = true -- prevent gc of root node
local destroy = function()
if not refs[node] then throw "root already destroyed" end
refs[node] = nil
destroy(node)
end
open_scope(node)
local result = { pcall(fn, destroy) }
close_scope()
if not result[1] then
refs[node] = nil
throw(`mount error\n{result}`)
end
return unpack(result :: any, 2)
end
return root :: (<T...>(fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ())

View file

@ -2,23 +2,30 @@ 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 create_start_node = graph.create_start_node
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_start_node(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

@ -24,10 +24,14 @@ Unsupported datatypes:
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local set_effect = graph.set_effect
local capture = graph.capture
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local create_start_node = graph.create_start_node
local get_scope = graph.get_scope
local evaluate_node = graph.evaluate_node
local update = graph.update
local set_owner = graph.set_owner
local track = graph.track
local UPDATE_RATE = 120
local TOLERANCE = 0.0001
@ -38,6 +42,8 @@ local function Vec3(x: number?, y: number?, z: number?)
return Vector3.new(x, y, z)
end
local ZERO = Vec3(0, 0, 0)
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData<T> = {
@ -62,21 +68,20 @@ type Vec6ToType<T> = (Vec3, Vec3) -> T
local type_to_vec6 = {
number = function(v)
return Vec3(v, 0, 0), Vec3()
return Vec3(v, 0, 0), ZERO
end :: TypeToVec6<number>,
CFrame = function(v)
-- todo: proper rotation tween
return v.Position, Vec3(v:ToEulerAnglesXYZ())
end :: TypeToVec6<CFrame>,
Color3 = function(v)
-- todo: hsv
return Vec3(v.R, v.G, v.B), Vec3()
-- todo: hsv, oklab?
return Vec3(v.R, v.G, v.B), ZERO
end :: TypeToVec6<Color3>,
UDim = function(v)
return Vec3(v.Scale, v.Offset, 0), Vec3()
return Vec3(v.Scale, v.Offset, 0), ZERO
end :: TypeToVec6<UDim>,
UDim2 = function(v)
@ -84,11 +89,11 @@ local type_to_vec6 = {
end :: TypeToVec6<UDim2>,
Vector2 = function(v)
return Vec3(v.X, v.Y, 0), Vec3()
return Vec3(v.X, v.Y, 0), ZERO
end :: TypeToVec6<Vector2>,
Vector3 = function(v)
return v, Vec3()
return v, ZERO
end :: TypeToVec6<Vector3>,
Rect = function(v)
@ -141,19 +146,16 @@ setmetatable(vec6_to_type, invalid_type)
-- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node
local springs: { [SpringData<any>]: Node<any> } = {}
local springs: { [SpringData<any>]: StartNode<any> } = {}
setmetatable(springs, { __mode = "v" })
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T
local inputs, initial_value = capture(source)
local output, output_get = create(initial_value)
local vtype = typeof(initial_value)
local x1_123, x1_456 = type_to_vec6[vtype](initial_value)
local owner = get_scope()
if not owner then
throw("cannot derive in non-reactive scope")
end; assert(owner)
-- https://en.wikipedia.org/wiki/Damping
-- todo: calculate damped freq at 10tau instead of natural freq
local w_n = 2*math.pi / (period or 1)
local z = damping_ratio or 1
@ -166,34 +168,42 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
k = k,
c = c,
x0_123 = x1_123,
x1_123 = x1_123,
v_123 = Vec3(),
x0_123 = ZERO,
x1_123 = ZERO,
v_123 = ZERO,
x0_456 = x1_456,
x1_456 = x1_456,
v_456 = Vec3(),
x0_456 = ZERO,
x1_456 = ZERO,
v_456 = ZERO,
source_value = initial_value,
source_value = false :: any,
}
local output = create_start_node(false :: any)
-- reschedule spring for simulation on input update
local function input_updated(node)
local v = source()
data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v)
data.source_value = v
springs[data] = node -- todo: investigate why insertion is not O(1) at ~20k springs
local function updater_effect()
local value = source()
data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value)
data.source_value = value
springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs
return value
end
-- unused field, use so output prevents gc of inputs
output.derive = source :: any
local updater = create_node(false :: any, updater_effect)
-- register above function as side-effect for all inputs
for _, input in next, inputs do
set_effect(input, input_updated, output)
set_owner(updater, owner)
evaluate_node(updater)
-- set initial position to goal
data.x0_123, data.x0_456 = data.x1_123, data.x1_456
-- set output to goal
output.cache = data.source_value
return function()
track(output)
return output.cache
end
return output_get, data
end
local function step_springs(dt: number)
@ -251,10 +261,12 @@ local function update_spring_sources()
if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then
-- close enough to target, unshedule spring and set value to target
table.insert(remove_queue, data)
set(output, data.source_value)
output.cache = data.source_value
else
set(output, vec6_to_type[typeof(data.source_value)](x0_123, x0_456))
output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456)
end
update(output)
end
for _, data in next, remove_queue do

71
src/switch.luau Normal file
View file

@ -0,0 +1,71 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node
local evaluate_node = graph.evaluate_node
local set_owner = graph.set_owner
local track = graph.track
local destroy = graph.destroy
local get_scope = graph.get_scope
local open_scope = graph.open_scope
local close_scope = graph.close_scope
type Map<K, V> = { [K]: V }
local function switch<T, U>(source: () -> T): (map: Map<T, ((() -> U)?)>) -> () -> U?
return function(map)
local owner = get_scope()
if not owner then
throw("cannot switch in non-reactive scope")
end; assert(owner)
local last_scope: Node<false>?
local last_component: (() -> U)?
local function update(cached): U?
local component = map[source()]
if component == last_component then return cached end
last_component = component
if last_scope then
destroy(last_scope :: Node<any>)
last_scope = nil
end
if component == nil then return nil end
if type(component) ~= "function" then
throw("map must map a value to a function")
end
local new_scope = create_node(false, false)
last_scope = new_scope :: Node<any>
set_owner(new_scope, owner)
open_scope(new_scope)
local ok, result = pcall(component)
close_scope()
if not ok then error(result, 0) end
return result
end
local node = create_node(nil :: any, update)
set_owner(node, owner)
evaluate_node(node)
return function()
track(node)
return node.cache
end
end
end
return switch

View file

@ -1,32 +1,9 @@
-- returns path to file as an array with each directory
-- accounts for Roblox and Luau contexts
local function get_path(s)
if string.sub(s, #s - 4, #s) == ".luau" then
s = string.sub(s, 1, #s - 5)
end
if not game then script = require "test/relative-string" end
return string.split(s, string.match(s, "%w+/") and "/" or ".")
end
local trace = require(script.Parent.trace)
-- get directory of vide root
local root do
local path = get_path(debug.info(1, "s"))
root = path[#path - 1]
end
-- throws an error, ensuring stack trace begins at the first callsite outside
-- of all vide library files
local function throw(msg: string)
local stack = 1
local path = get_path(debug.info(stack, "s"))
while path[#path] == root or path[#path - 1] == root do
stack += 1
path = get_path(debug.info(stack, "s"))
end
error(msg, stack)
local function throw(msg): any
error(msg, trace()-1)
end
return throw

29
src/trace.luau Normal file
View file

@ -0,0 +1,29 @@
-- returns path to file as an array with each directory
-- accounts for Roblox and Luau contexts
local function get_path(s)
if string.sub(s, #s - 4, #s) == ".luau" then
s = string.sub(s, 1, #s - 5)
end
return string.split(s, string.match(s, "%w+/") and "/" or ".")
end
-- get directory of vide root
local root do
local path = get_path(debug.info(1, "s"))
root = path[#path - 1]
end
-- finds the first stack depth outside of any vide library function
return function(): number
local stack = 1
local path = get_path(debug.info(stack, "s"))
while path[#path] == root or path[#path - 1] == root do
stack += 1
path = get_path(debug.info(stack, "s"))
end
return stack
end

View file

@ -1,20 +1,27 @@
if not game then script = require "test/relative-string" end
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local refs = graph.refs
local get_scope = graph.get_scope
local function untrack<T>(source: () -> T): T
local initial = #refs
local scope = get_scope()
if not scope then
throw("cannot untrack in non-reactive scope")
end; assert(scope)
local value = source()
-- sources are only tracked if the node in scope has an effect
local effect = scope.effect
scope.effect = false
-- remove any references made since `untrack()` was called
for i = initial, #refs do
refs[i] = nil
end
local ok, result = pcall(source)
return value
scope.effect = effect :: () -> ()
if not ok then error(result, 0) end
return result
end
return untrack

View file

@ -1,28 +0,0 @@
if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph)
local set_effect = graph.set_effect
local capture = graph.capture
local function watch(effect: () -> ()): () -> ()
local nodes = capture(effect :: () -> nil)
-- store aside captured nodes in new table
nodes = table.clone(nodes)
-- register effect with permanent lifetime
for _, node in next, nodes do
set_effect(node, effect, true)
end
local function unwatch()
-- unregister effect from all nodes
for _, node in next, nodes do
set_effect(node, effect, nil)
end
end
return unwatch
end
return watch