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

View file

@ -1,22 +1,41 @@
local warn = warn if not game then script = require "test/relative-string" end
local warn = game and warn or print :: never
if not game then
script = require "test/relative-string"
warn = print
end
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local set_effect = graph.set_effect local set_effect = graph.set_effect
local capture = graph.capture local capture = graph.capture
local throw = require(script.Parent.throw) --[[
local flags = require(script.Parent.flags)
local hold: { Instance? } = {} Roblox instances in Luau are referenced using a kind of userdata proxy,
local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any this proxy can be garbage collected independently from the actual instance, even
local bindcount = 0 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 root do
local src = debug.info(1, "s") local src = debug.info(1, "s")
root = string.sub(src, 1, #src - 5) 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) return debug.traceback(nil, s)
end end
function setup(instance: Instance, debug_msg: string, setter: (Instance) -> ()) function bind(instance: Instance, property: string, setter: (Instance) -> ())
if flags.strict then if flags.strict then
-- wrap setter in function with stack inspection for better error msgs
local fn = setter local fn = setter
local bind_trace = traceback(0) local bind_trace = traceback(0)
setter = function(instance) setter = function(instance)
local ok, err: string? = xpcall(fn, function(err: string) local ok, err: string? = xpcall(fn, function(err: string)
return err .. "\nsource updated at: " .. traceback(2) return err .. "\nsource updated at: " .. traceback(2)
end, instance) 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
end end
-- run setter to capture any nodes being depended on
local nodes = (capture(setter :: () -> unknown, instance)) local nodes = (capture(setter :: () -> unknown, instance))
-- register the setter as a side-effect of each node
for _, node in next, nodes do for _, node in next, nodes do
set_effect(node, setter, instance) set_effect(node, setter, instance)
end end
bindcount += 1 -- get binding id
local key = bindcount 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 function ref()
local _ = setter local _ = setter -- prevent gc of nodes being depended on
local instance = weak[key] :: Instance local instance = weak[bind_id] :: Instance
hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented
-- keep proxy in memory if instance is still parented
hold[bind_id] = instance.Parent and instance or nil
end end
ref() ref()
@ -73,7 +99,7 @@ function setup(instance: Instance, debug_msg: string, setter: (Instance) -> ())
end end
local function bind_property(instance: Instance, property: string, fn: () -> unknown) 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() instance_weak[property] = fn()
end) end)
end end
@ -83,7 +109,7 @@ local function bind_parent(instance: Instance, fn: () -> Instance?)
instance = nil :: any -- allow gc when destroyed instance = nil :: any -- allow gc when destroyed
end) end)
setup(instance, "Parent", function(instance) bind(instance, "Parent", function(instance)
local _ = instance -- state will strongly reference instance when parent is bound local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = fn() instance.Parent = fn()
end) 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 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 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 local new_childs = fn() -- all (and only) children that should be parented after this update
if new_childs and type(new_childs) ~= "table" then if new_childs and type(new_childs) ~= "table" then
throw(`Cannot parent instance of type { type(new_childs) } `) 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 flags = require(script.Parent.flags)
local throw = require(script.Parent.throw) 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 } local ref_to_id = {} :: { [string]: number }
-- maps a cleanup id to a ref
local id_to_ref = {} :: { [number]: string } local id_to_ref = {} :: { [number]: string }
-- array of all cleanup callbacks
local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense
-- weak array of all cleanup lifetimes
local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse
setmetatable(cleanup_lifetime :: any, { __mode = "v" }) 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 } local debug_caller_to_line = {} :: { [() -> ()]: number }
setmetatable(debug_caller_to_line, { __mode = "k" }) 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 = { local manual_mode = {
caller = false :: false | () -> (), caller = false :: false | () -> (),
callbacks = {} :: { () -> () } callbacks = {} :: { () -> () }
@ -20,13 +46,14 @@ local manual_mode = {
-- todo: rare case where mem address is reused by another function -- todo: rare case where mem address is reused by another function
-- does this case handle itself? -- 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 function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
local id = ref_to_id[ref] local id = ref_to_id[ref]
if id then if id then -- invoke previously registered callback then register new one
cleanup_callbacks[id]() cleanup_callbacks[id]()
cleanup_lifetime[id] = lifetime -- rare case where ref is reused while lifetime is nil 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 id = #cleanup_callbacks + 1
ref_to_id[ref] = id ref_to_id[ref] = id
id_to_ref[id :: any] = ref -- todo id_to_ref[id :: any] = ref -- todo
@ -36,6 +63,7 @@ local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
cleanup_callbacks[id] = callback cleanup_callbacks[id] = callback
end end
-- registers a callback with its caller as the lifetime, and caller address as the ref
local function cleanup(callback: () -> ()) local function cleanup(callback: () -> ())
local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup` local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup`

View file

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

View file

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

View file

@ -5,11 +5,11 @@ local create = graph.create
local capture_and_link = graph.capture_and_link local capture_and_link = graph.capture_and_link
local function derive<T>(fn: () -> T): () -> T 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) node.cache = capture_and_link(node, fn)
return node_get return read_node_value
end end
return derive return derive

View file

@ -10,7 +10,9 @@ export type Node<T> = {
children: { Node<T> } | false -- weak values children: { Node<T> } | false -- weak values
} }
-- flag used to detect when node reference capturing is active
local reff = false local reff = false
-- array of all nodes referenced since above flag was set
local refs = {} :: { Node<unknown> } local refs = {} :: { Node<unknown> }
local WEAK_VALUES_RESIZABLE = { __mode = "vs" } 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) 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 check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
local t = { __mode = "kv" } local t = { __mode = "kv" }
setmetatable(t, t) setmetatable(t, t)
@ -43,12 +46,23 @@ local check_for_yield: <T...>(fn: (T...) -> unknown, T...) -> () do
end end
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) local function set_effect<T>(node: Node<unknown>, fn: (T) -> (), key: T)
node.effects[fn :: () -> ()] = key node.effects[fn :: () -> ()] = key
end end
local function run_effects(node: Node<unknown>) 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 for effect, key in next, node.effects do
effect(key) effect(key)
effect(key) effect(key)
@ -61,12 +75,13 @@ local function run_effects(node: Node<unknown>)
end end
-- retrieves a node's cached value -- 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 local function get<T>(node: Node<T>): T
if reff then table.insert(refs, node) end if reff then table.insert(refs, node) end
return node.cache return node.cache
end end
-- links two nodes as parent-child
local function set_child(parent: Node<unknown>, child: Node<unknown>) local function set_child(parent: Node<unknown>, child: Node<unknown>)
if parent.children then if parent.children then
table.insert(parent.children, child) table.insert(parent.children, child)
@ -144,11 +159,11 @@ local function create<T>(value: T): (Node<T>, () -> T)
children = false :: false children = false :: false
} }
local function get_value() local function read_node_value()
return get(node) return get(node)
end end
return node, get_value return node, read_node_value
end end
return table.freeze { return table.freeze {

View file

@ -52,35 +52,50 @@ local vide = {
-- runtime -- runtime
step = function(dt: number) step = function(dt: number)
-- debug.profilebegin("VIDE STEP") if game then
-- debug.profilebegin("VIDE SPRING") debug.profilebegin("VIDE STEP")
debug.profilebegin("VIDE SPRING")
end
update_springs(dt) update_springs(dt)
-- debug.profileend()
-- debug.profilebegin("VIDE GARBAGE CLEANUP") if game then
debug.profileend()
debug.profilebegin("VIDE GARBAGE CLEANUP")
end
clean_garbage() clean_garbage()
-- debug.profileend()
-- debug.profileend() if game then
debug.profileend()
debug.profileend()
end
end end
} }
setmetatable(vide :: any, { do
__index = function(_, index: unknown): () local set = false
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) setmetatable(vide :: any, {
if index == "strict" then __index = function(_, index: unknown): ()
if value ~= true then throw "strict mode can only be set to true" end if index == "strict" then
flags.strict = true return flags.strict
else else
throw(`{tostring(index)} is not a valid member of vide`) 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 })
}) end
if game then if game then
game:GetService("RunService").Heartbeat:Connect(function(dt: number) 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 return output_array
end end
local output, output_get = create(nil :: any) local output, read_output_value = create(nil :: any)
local function derive() local function derive()
return recompute(input()) return recompute(input())
@ -108,7 +108,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
end end
end) end)
return output_get return read_output_value
end end
-- todo: optimize output array -- todo: optimize output array
@ -182,7 +182,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
return output_array return output_array
end end
local output, output_get = create(nil :: any) local output, read_output_value = create(nil :: any)
local function derive() local function derive()
return recompute(input()) return recompute(input())
@ -205,7 +205,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
end end
end) end)
return output_get return read_output_value
end end
return function() return indexes, values 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 function memoize<X, Y>(f: (X) -> Y): (X) -> Y
local cache: { [X]: Y } = {} local cache: { [X]: Y? } = {}
return function(x: X): Y return function(x: X): Y
local y: Y? = cache[x] local y = cache[x]
if not y then if not y then
y = f(x) y = f(x)
@ -10,7 +10,7 @@ local function memoize<X, Y>(f: (X) -> Y): ((X) -> Y, { [X]: Y })
end end
return y :: Y return y :: Y
end, cache end
end end
return memoize return memoize

View file

@ -5,14 +5,13 @@ type Node<T> = graph.Node<T>
local create = graph.create local create = graph.create
local set = graph.set local set = graph.set
export type Source<T> = (() -> T) & ((T) -> T) export type Source<T> = (() -> T) & ((T) -> T)
local function source<T>(value: T): Source<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 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 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

View file

@ -21,7 +21,6 @@ Unsupported datatypes:
]] ]]
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create = graph.create local create = graph.create
@ -51,6 +50,7 @@ type SpringData<T> = {
type Lerp<T> = (initial: T, target: T, alpha: number) -> 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 function solve(T: number, z: number, u: number, t: number): number -- alpha
local wn = 2*math.pi / T local wn = 2*math.pi / T
local wd = wn * math.sqrt(1 - z^2) local wd = wn * math.sqrt(1 - z^2)
@ -94,6 +94,8 @@ local lerpable: { [string]: Lerp<any> } = {
end :: Lerp<Vector3>, 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> } = {} local springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" }) setmetatable(springs, { __mode = "vs" })
@ -103,7 +105,6 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
end end
local inputs, initial_position = capture(target) local inputs, initial_position = capture(target)
local output, output_get = create(initial_position) local output, output_get = create(initial_position)
local data: SpringData<T> = { local data: SpringData<T> = {
@ -121,14 +122,16 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
target = target 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_updated = true
data.target_position = target() data.target_position = target()
springs[data] = node springs[data] = node
end end
-- register above function as side-effect for all inputs
for _, input in next, inputs do for _, input in next, inputs do
set_effect(input, input_changed, output) set_effect(input, input_updated, output)
end end
springs[data] = output springs[data] = output
@ -136,6 +139,8 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
return output_get return output_get
end end
-- `springs` is a hashmap, use array to queue indexes-to-remove to avoid
-- iterator invalidation of `springs`
local remove_queue = {} local remove_queue = {}
local function update_springs(dt: number) 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) local value = lerp(initial_position, target_position, new_alpha)
if math.abs(1 - new_alpha) < TOLERANCE and math.abs(new_velocity) < TOLERANCE then 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) table.insert(remove_queue, data)
set(output, target_position) set(output, target_position)
else 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) local function get_path(s)
if string.sub(s, #s - 4, #s) == ".luau" then if string.sub(s, #s - 4, #s) == ".luau" then
s = string.sub(s, 1, #s - 5) 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 ".") return string.split(s, string.match(s, "%w+/") and "/" or ".")
end end
-- get directory of vide root
local root do local root do
local path = get_path(debug.info(1, "s")) local path = get_path(debug.info(1, "s"))
root = path[#path - 1] root = path[#path - 1]
end end
-- throws an error, ensuring stack trace begins at the first callsite outside
-- of all vide library files
local function throw(msg: string) local function throw(msg: string)
local stack = 1 local stack = 1

View file

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

View file

@ -7,13 +7,16 @@ local capture = graph.capture
local function watch(effect: () -> ()): () -> () local function watch(effect: () -> ()): () -> ()
local nodes = capture(effect :: () -> nil) local nodes = capture(effect :: () -> nil)
-- store aside captured nodes in new table
nodes = table.clone(nodes) nodes = table.clone(nodes)
-- register effect with permanent lifetime
for _, node in next, nodes do for _, node in next, nodes do
set_effect(node, effect, true) set_effect(node, effect, true)
end end
local function unwatch() local function unwatch()
-- unregister effect from all nodes
for _, node in next, nodes do for _, node in next, nodes do
set_effect(node, effect, nil) set_effect(node, effect, nil)
end end