Refactor codebase

This commit is contained in:
Aaron Smith 2023-08-22 15:50:03 +01:00
parent 07ae542e28
commit a3f03fd067
15 changed files with 228 additions and 128 deletions

View file

@ -1,13 +1,7 @@
local typeof = typeof
local Vector2 = Vector2
local UDim2 = UDim2
if not game then
script = require "test/relative-string"
typeof = require "test/mock".typeof
Vector2 = require "test/mock".Vector2
UDim2 = require "test/mock".UDim2
end
if not game then script = require "test/relative-string" end
local typeof = game and typeof or require "test/mock".typeof :: never
local Vector2 = game and Vector2 or require "test/mock".Vector2 :: never
local UDim2 = game and UDim2 or require "test/mock".UDim2 :: never
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
@ -16,9 +10,11 @@ local _, is_action = require(script.Parent.action)()
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
-- buffer of event -> callback to connect after properties are set
local event_buffer: { [string]: () -> () } = {}
local action_buffers = {} :: { { () -> () } }
-- buffer of priority -> callback to run after events are connected
local action_buffers = {} :: { { () -> () } }
setmetatable(action_buffers :: any, {
__index = function(_, i: number)
action_buffers[i] = {}
@ -26,8 +22,8 @@ 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 } } = {}
setmetatable(nested_debug_cache :: any, {
__index = function(_, i: number)
nested_debug_cache[i] = {}
@ -35,10 +31,15 @@ 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 }
local nested_stack = {} :: { {} | number }
-- todo
local classes = {
-- todo: solution without manual updating of this table
-- map of datatype names to class default constructor for aggregate initialization
local aggregates = {
Vector2 = Vector2,
UDim2 = UDim2,
UDim = UDim2,
@ -46,11 +47,12 @@ local classes = {
Color3 = Color3
}
local function get_class(v: unknown): { new: (...any) -> () }
return classes[typeof(v)]
for i, v in next, aggregates do
aggregates[i] = v.new
end
local function process(instance: Instance, properties: { [unknown]: unknown })
-- processes a potentially nested table of values to assign to an instance
local function process_nested(instance: Instance, properties: { [unknown]: unknown })
local strict = flags.strict
table.clear(nested_stack)
@ -61,71 +63,79 @@ local function process(instance: Instance, properties: { [unknown]: unknown })
repeat
for property, value in properties do
if type(property) == "string" then
if strict then
if strict then -- check for duplicate prop assignment at nesting layer
if nested_debug_cache[depth][property] then
throw(`duplicate property {property} at depth {depth}`)
end
nested_debug_cache[depth][property] = true
end
if type(value) == "table" then
local class = get_class((instance :: any)[property])
if class == nil then
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}`)
end
(instance :: any)[property] = class.new(unpack(value :: {}))
(instance :: any)[property] = ctor(unpack(value :: {}))
elseif type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> ()
event_buffer[property] = value :: () -> () -- add event to buffer
else
bind.property(instance, property, value :: () -> ())
bind.property(instance, property, value :: () -> ()) -- bind source
end
else
(instance :: any)[property] = value
(instance :: any)[property] = value -- set property
end
elseif type(property) == "number" then
if type(value) == "function" then
bind.children(instance, value :: () -> { Instance })
bind.children(instance, value :: () -> { Instance }) -- bind children
elseif type(value) == "table" then
if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ())
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer
else
table.insert(nested_stack, depth + 1)
table.insert(nested_stack, depth + 1) -- push table to stack for later processing
table.insert(nested_stack, value :: {})
end
else
(value :: Instance).Parent = instance
(value :: Instance).Parent = instance -- parent child
end
end
end
properties = table.remove(nested_stack) :: {}
depth = table.remove(nested_stack) :: number
-- pop next nested table off stack
properties = table.remove(nested_stack) :: {}
depth = table.remove(nested_stack) :: number
until not properties
end
-- applies table of nested properties to an instance using full vide semantics
local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T
-- queue parent assignment if any for last
local parent: unknown = properties.Parent
if parent then properties.Parent = nil end
-- reset buffers
table.clear(event_buffer)
for _, buffer in next, action_buffers do
table.clear(buffer)
end
process(instance, properties)
-- process all properties for immediate setting or buffering
process_nested(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
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback()
end
end
-- finally set parent if any
if parent then
if type(parent) == "function" then
error("cannot set parent to state")

View file

@ -1,22 +1,41 @@
local warn = warn
if not game then
script = require "test/relative-string"
warn = print
end
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 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 throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
--[[
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? } = {}
local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any
local bindcount = 0
-- 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)
@ -39,33 +58,40 @@ local function traceback(skips: number) -- ensures trace begins outside of any v
return debug.traceback(nil, s)
end
function setup(instance: Instance, debug_msg: string, setter: (Instance) -> ())
function bind(instance: Instance, property: string, setter: (Instance) -> ())
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 {debug_msg}: {err}bound at: {bind_trace}`) end
if not ok then warn(`error occured updating {property}: {err}bound at: {bind_trace}`) end
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
bindcount += 1
local key = bindcount
-- get binding id
bind_count += 1
local bind_id = bind_count
weak[key] = instance
-- store reference of instance proxy without preventing gc
weak[bind_id] = instance
local function ref()
local _ = setter
local instance = weak[key] :: Instance
hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented
local _ = setter -- prevent gc of nodes 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()
@ -73,7 +99,7 @@ function setup(instance: Instance, debug_msg: string, setter: (Instance) -> ())
end
local function bind_property(instance: Instance, property: string, fn: () -> unknown)
setup(instance, property, function(instance_weak: any)
bind(instance, property, function(instance_weak: any)
instance_weak[property] = fn()
end)
end
@ -83,7 +109,7 @@ local function bind_parent(instance: Instance, fn: () -> Instance?)
instance = nil :: any -- allow gc when destroyed
end)
setup(instance, "Parent", function(instance)
bind(instance, "Parent", function(instance)
local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = fn()
end)
@ -93,7 +119,7 @@ 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
setup(parent, "Children", function(parent_weak)
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) } `)

View file

@ -3,15 +3,41 @@ if not game then script = require "test/relative-string" end
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
--[[
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 = {} :: { () -> () }
@ -20,13 +46,14 @@ local manual_mode = {
-- 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
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
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
@ -36,6 +63,7 @@ local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
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`

View file

@ -1,22 +1,17 @@
local Instance = Instance
local typeof = typeof
if not game then
script = require "test/relative-string"
Instance = require("test/mock").Instance
typeof = require("test/mock").typeof
end
if not game then script = require "test/relative-string" end
local typeof = game and typeof or require "test/mock".typeof:: never
local Instance = game and Instance or require "test/mock".Instance :: never
local throw = require(script.Parent.throw)
local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply)
local memoize = require(script.Parent.memoize)
local function create_instance(className: string)
local success, instance: Instance = pcall(Instance.new, className :: any)
if success == false then throw(`invalid class name, could not create instance of class { className }`) end
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 default: { [string]: unknown }? = defaults[className]
local default: { [string]: unknown }? = defaults[class_name]
if default then
for i, v in next, default do
(instance :: any)[i] = v
@ -26,7 +21,7 @@ local function create_instance(className: string)
return function(properties: { [any]: unknown }): Instance
return apply(instance:Clone(), properties)
end
end; create_instance = memoize(create_instance)
end; create_instance = memoize(create_instance) -- always return same constructor for given class
local function clone_instance(instance: Instance)
return function(properties: { [any]: unknown }): Instance
@ -36,14 +31,15 @@ local function clone_instance(instance: Instance)
end
end
local function create(classNameOrInstance: string|Instance)
if type(classNameOrInstance) == "string" then
return create_instance(classNameOrInstance)
elseif typeof(classNameOrInstance) == "Instance" then
return clone_instance(classNameOrInstance)
local function create(class_or_instance: string|Instance)
if type(class_or_instance) == "string" then
return create_instance(class_or_instance)
elseif typeof(class_or_instance) == "Instance" then
return clone_instance(class_or_instance)
else
error("Bad argument #1, expected string or instance, got "..typeof(classNameOrInstance), 2)
throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance))
end
return nil :: never
end
type Props = { [any]: any }

View file

@ -1,11 +1,5 @@
local Enum = Enum
local Color3 = Color3
if not game then
local mock = require "test/mock"
Enum = mock.Enum
Color3 = mock.Color3
end
local Enum = game and Enum or require "test/mock".Enum :: never
local Color3 = game and Color3 or require "test/mock".Color3 :: never
return {
Part = {

View file

@ -5,11 +5,11 @@ local create = graph.create
local capture_and_link = graph.capture_and_link
local function derive<T>(fn: () -> T): () -> T
local node, node_get = create((nil :: any) :: T)
local node, read_node_value = create((false :: any) :: T)
node.cache = capture_and_link(node, fn)
return node_get
return read_node_value
end
return derive

View file

@ -10,7 +10,9 @@ export type Node<T> = {
children: { Node<T> } | false -- weak values
}
-- 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> }
local WEAK_VALUES_RESIZABLE = { __mode = "vs" }
@ -18,6 +20,7 @@ local EVALUATION_ERR = "error while evaluating source:\n\n"
setmetatable(refs :: any, WEAK_VALUES_RESIZABLE)
-- 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 t = { __mode = "kv" }
setmetatable(t, t)
@ -43,12 +46,23 @@ local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
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
end
local function run_effects(node: Node<unknown>)
if flags.strict then
if flags.strict then -- run effects twice if strict
for effect, key in next, node.effects do
effect(key)
effect(key)
@ -61,12 +75,13 @@ local function run_effects(node: Node<unknown>)
end
-- retrieves a node's cached value
-- recalculates value if an ancestor was updated
-- 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
-- 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)
@ -144,11 +159,11 @@ local function create<T>(value: T): (Node<T>, () -> T)
children = false :: false
}
local function get_value()
local function read_node_value()
return get(node)
end
return node, get_value
return node, read_node_value
end
return table.freeze {

View file

@ -52,35 +52,50 @@ local vide = {
-- runtime
step = function(dt: number)
-- debug.profilebegin("VIDE STEP")
-- debug.profilebegin("VIDE SPRING")
if game then
debug.profilebegin("VIDE STEP")
debug.profilebegin("VIDE SPRING")
end
update_springs(dt)
-- debug.profileend()
-- debug.profilebegin("VIDE GARBAGE CLEANUP")
if game then
debug.profileend()
debug.profilebegin("VIDE GARBAGE CLEANUP")
end
clean_garbage()
-- debug.profileend()
-- debug.profileend()
if game then
debug.profileend()
debug.profileend()
end
end
}
setmetatable(vide :: any, {
__index = function(_, index: unknown): ()
if index == "strict" then
return flags.strict
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end,
do
local set = false
__newindex = function(_, index: unknown, value: unknown)
if index == "strict" then
if value ~= true then throw "strict mode can only be set to true" end
flags.strict = true
else
throw(`{tostring(index)} is not a valid member of vide`)
setmetatable(vide :: any, {
__index = function(_, index: unknown): ()
if index == "strict" then
return flags.strict
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end,
__newindex = function(_, index: unknown, value: unknown)
if index == "strict" then
if set then throw "strict mode has already been set" end
set = true
flags.strict = value :: boolean
else
throw(`{tostring(index)} is not a valid member of vide`)
end
end
end
})
})
end
if game then
game:GetService("RunService").Heartbeat:Connect(function(dt: number)

View file

@ -86,7 +86,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
return output_array
end
local output, output_get = create(nil :: any)
local output, read_output_value = create(nil :: any)
local function derive()
return recompute(input())
@ -108,7 +108,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
end
end)
return output_get
return read_output_value
end
-- todo: optimize output array
@ -182,7 +182,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
return output_array
end
local output, output_get = create(nil :: any)
local output, read_output_value = create(nil :: any)
local function derive()
return recompute(input())
@ -205,7 +205,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
end
end)
return output_get
return read_output_value
end
return function() return indexes, values end

View file

@ -1,8 +1,8 @@
local function memoize<X, Y>(f: (X) -> Y): ((X) -> Y, { [X]: Y })
local cache: { [X]: Y } = {}
local function memoize<X, Y>(f: (X) -> Y): (X) -> Y
local cache: { [X]: Y? } = {}
return function(x: X): Y
local y: Y? = cache[x]
local y = cache[x]
if not y then
y = f(x)
@ -10,7 +10,7 @@ local function memoize<X, Y>(f: (X) -> Y): ((X) -> Y, { [X]: Y })
end
return y :: Y
end, cache
end
end
return memoize

View file

@ -5,14 +5,13 @@ type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
export type Source<T> = (() -> T) & ((T) -> T)
local function source<T>(value: T): Source<T>
local node, get_value = create(value :: T)
local node, read_node_value = create(value :: T)
return function(...): T
if select("#", ...) == 0 then return get_value() end
if select("#", ...) == 0 then return read_node_value() end -- check if any args were given
local v = ... :: T
if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then return v end

View file

@ -21,7 +21,6 @@ Unsupported datatypes:
]]
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
@ -51,6 +50,7 @@ type SpringData<T> = {
type Lerp<T> = (initial: T, target: T, alpha: number) -> T
-- period, damping ratio, initial velocity, total time
local function solve(T: number, z: number, u: number, t: number): number -- alpha
local wn = 2*math.pi / T
local wd = wn * math.sqrt(1 - z^2)
@ -94,6 +94,8 @@ local lerpable: { [string]: Lerp<any> } = {
end :: Lerp<Vector3>,
}
-- maps spring data to its corresponding output node
-- lifetime of spring data is tied to output node's
local springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" })
@ -103,7 +105,6 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
end
local inputs, initial_position = capture(target)
local output, output_get = create(initial_position)
local data: SpringData<T> = {
@ -121,14 +122,16 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
target = target
}
local function input_changed(node)
-- reschedule spring for simulation on input update
local function input_updated(node)
data.target_updated = true
data.target_position = target()
springs[data] = node
end
-- register above function as side-effect for all inputs
for _, input in next, inputs do
set_effect(input, input_changed, output)
set_effect(input, input_updated, output)
end
springs[data] = output
@ -136,6 +139,8 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
return output_get
end
-- `springs` is a hashmap, use array to queue indexes-to-remove to avoid
-- iterator invalidation of `springs`
local remove_queue = {}
local function update_springs(dt: number)
@ -183,7 +188,7 @@ local function update_springs(dt: number)
local value = lerp(initial_position, target_position, new_alpha)
if math.abs(1 - new_alpha) < TOLERANCE and math.abs(new_velocity) < TOLERANCE then
-- close enough to target, remove and set value to target
-- close enough to target, unshedule spring and set value to target
table.insert(remove_queue, data)
set(output, target_position)
else

View file

@ -1,3 +1,5 @@
-- 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)
@ -6,11 +8,14 @@ local function get_path(s)
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
-- 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

View file

@ -6,10 +6,14 @@ local refs = graph.refs
local function untrack<T>(source: () -> T): T
local initial = #refs
local value = source()
-- remove any references made since `untrack()` was called
for i = initial, #refs do
refs[i] = nil
end
return value
end

View file

@ -7,13 +7,16 @@ 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