This commit is contained in:
Aaron Smith 2023-09-11 17:41:05 +01:00
parent dcc40b4dc1
commit 52ea2e4690
9 changed files with 147 additions and 102 deletions

View file

@ -9,38 +9,7 @@ local create_node = graph.create_node
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local track = graph.track local set_owner = graph.set_owner
local add_child = graph.add_child
--[[
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.
]]
type Binding = {
instance: Instance,
property: string,
source: () -> unknown
}
local function binder(b: Binding)
(b.instance :: any)[b.property] = b.source()
end
-- todo: replace with throw's method -- todo: replace with throw's method
local root do local root do
@ -65,7 +34,7 @@ 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 create_binding<T>(updater: (T) -> (), binding_data: T) function create_binding<T>(updater: (T) -> T, binding_data: T)
-- if flags.strict then -- if flags.strict then
-- -- wrap setter in function with stack inspection for better error msgs -- -- wrap setter in function with stack inspection for better error msgs
-- local fn = setter -- local fn = setter
@ -84,7 +53,7 @@ function create_binding<T>(updater: (T) -> (), binding_data: T)
local owner = get_scope() local owner = get_scope()
assert(owner) assert(owner)
add_child(owner, binding) set_owner(binding, owner)
open_scope(binding) open_scope(binding)
updater(binding_data) updater(binding_data)
@ -100,6 +69,7 @@ type PropertyBinding = {
local function update_property(p: PropertyBinding) local function update_property(p: PropertyBinding)
(p.instance :: any)[p.property] = p.source() (p.instance :: any)[p.property] = p.source()
return p
end end
type ParentBinding = { type ParentBinding = {
@ -109,6 +79,7 @@ type ParentBinding = {
local function update_parent(p: ParentBinding) local function update_parent(p: ParentBinding)
p.instance.Parent = p.parent() p.instance.Parent = p.parent()
return p
end end
type ChildrenBinding = { type ChildrenBinding = {
@ -145,6 +116,8 @@ local function update_children(p: ChildrenBinding)
table.clear(cur_children_set) -- clear cache, preserve capacity table.clear(cur_children_set) -- clear cache, preserve capacity
p.cur_children_set, p.new_children_set = new_child_set, cur_children_set p.cur_children_set, p.new_children_set = new_child_set, cur_children_set
return p
end end
return { return {

View file

@ -2,8 +2,7 @@ if not game then script = require "test/relative-string" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
local create_node = graph.create_node local create_node = graph.create_node
local add_child = graph.add_child local set_owner = graph.set_owner
local update = graph.update
local track = graph.track local track = graph.track
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
@ -15,11 +14,10 @@ local function derive<T>(fn: () -> T): () -> T
local node = create_node((false :: any) :: T) local node = create_node((false :: any) :: T)
node.effect = function() node.effect = function()
node.cache = fn() return fn()
end end
add_child(owner, node) set_owner(node, owner)
open_scope(node) open_scope(node)
node.cache = fn() node.cache = fn()

View file

@ -10,9 +10,10 @@ export type StartNode<T> = {
export type Node<T> = { export type Node<T> = {
cache: T, cache: T,
owner: Node<T> | false,
parents: { StartNode<T> }, parents: { StartNode<T> },
children: { Node<T> } | false, children: { Node<T> } | false,
effect: (T) -> () | false, effect: ((T) -> T) | false,
cleanups: { () -> () } | false, cleanups: { () -> () } | false,
} }
@ -56,6 +57,7 @@ local function get_scope(): Node<unknown>?
return scopes[scopes.n] return scopes[scopes.n]
end end
local function add_child<T>(parent: StartNode<any>, child: Node<any>) local function add_child<T>(parent: StartNode<any>, child: Node<any>)
if parent.children then if parent.children then
table.insert(parent.children :: { Node<T> }, child) table.insert(parent.children :: { Node<T> }, child)
@ -66,6 +68,15 @@ local function add_child<T>(parent: StartNode<any>, child: Node<any>)
table.insert(child.parents, parent) table.insert(child.parents, parent)
end end
local function set_owner(node: Node<any>, owner: Node<any>)
node.owner = owner
if owner.children then
table.insert(owner.children :: { Node<any> }, node)
else
owner.children = { node }
end
end
local function open_scope<T>(node: Node<T>) local function open_scope<T>(node: Node<T>)
local n = scopes.n + 1 local n = scopes.n + 1
scopes.n = n scopes.n = n
@ -95,20 +106,18 @@ local function run_cleanups<T>(node: Node<T>)
end end
end end
local function run_effect<T>(node: Node<T>) local function remove_child<T>(parent: StartNode<T>, child: Node<T>)
if node.effect then local children = parent.children :: {}
node.effect(node.cache) local idx = table.find(children :: {}, child)
end
local n = #children
children[idx] = children[n]
children[n] = nil
end end
local function unparent<T>(node: Node<T>) local function unparent<T>(node: Node<T>)
for _, parent in node.parents do for _, parent in node.parents do
local children = parent.children :: {} remove_child(parent, node)
local idx = table.find(children :: {}, node)
local n = #children
children[idx] = children[n]
children[n] = nil
end end
table.clear(node.parents) table.clear(node.parents)
@ -117,6 +126,8 @@ end
local function destroy<T>(node: Node<T>) local function destroy<T>(node: Node<T>)
run_cleanups(node) run_cleanups(node)
unparent(node) unparent(node)
if node.owner then remove_child(node.owner, node) end
node.owner = false
local children = node.children :: {} local children = node.children :: {}
if children then if children then
while children[1] do destroy(children[1]) end while children[1] do destroy(children[1]) end
@ -125,36 +136,77 @@ end
local update_queue = {} :: { Node<any> } local update_queue = {} :: { Node<any> }
-- runs node effects, recalculates descendants and runs descendant effects -- -- runs node effects, recalculates descendants and runs descendant effects
local function rec<T>(node: StartNode<T>) -- local function rec<T>(node: StartNode<T>)
if not node.children then return end -- if not node.children then return end
local children = node.children :: {} -- local children = node.children :: {}
while children[1] do -- while children[1] do
table.insert(update_queue, children[1]) -- table.insert(update_queue, children[1])
rec(children[1]) -- rec(children[1])
unparent(children[1]) -- unparent(children[1])
end -- end
table.clear(children) -- table.clear(children)
end -- end
-- local function update<T>(node: StartNode<T>)
-- --assert(#update_queue == 0, "update already in progress")
-- -- check if recursive update
-- local first = update_queue[1] == nil
-- rec(node)
-- if first then
-- for _, n in next, update_queue do
-- open_scope(n) -- todo
-- run_cleanups(n)
-- run_effect(n)
-- close_scope()
-- end
-- table.clear(update_queue)
-- end
-- end
local function update<T>(node: StartNode<T>) local function update<T>(node: StartNode<T>)
--assert(#update_queue == 0, "update already in progress") local children = node.children :: {}
-- check if recursive update if not children then return end
local first = update_queue[1] == nil
rec(node) local n0 = #update_queue
local first_update = n0 == 0
local n = n0
if first then do
for _, n in next, update_queue do local child = children[1]
open_scope(n) -- todo while child do -- todo: case where child in owner context
run_cleanups(n) unparent(child)
run_effect(n)
close_scope() n += 1
update_queue[n] = child
child = children[1]
end end
end
for i = n0 + 1, n do
local child = update_queue[i]
local old_value = child.cache
open_scope(child)
run_cleanups(child)
local new_value = child.effect and child.effect(old_value)
close_scope()
if old_value ~= new_value then
child.cache = new_value
update(child)
end
end
if first_update then
table.clear(update_queue) table.clear(update_queue)
end end
end end
@ -169,7 +221,8 @@ end
local function create_node<T>(value: T): Node<T> local function create_node<T>(value: T): Node<T>
local node: Node<T> = { local node: Node<T> = {
cache = value, cache = value,
effect = false, owner = false,
effect = false :: false,
cleanups = false :: false, cleanups = false :: false,
parents = {}, parents = {},
children = false :: false children = false :: false
@ -200,6 +253,7 @@ return table.freeze {
get_scope = get_scope, get_scope = get_scope,
get_stack_scope = get_stack_scope, get_stack_scope = get_stack_scope,
add_cleanup = add_cleanup, add_cleanup = add_cleanup,
set_owner = set_owner,
destroy = destroy, destroy = destroy,
run_cleanups = run_cleanups, run_cleanups = run_cleanups,
track = track, track = track,

View file

@ -9,6 +9,7 @@ type Scope = graph.Scope
type StartNode<T> = graph.StartNode<T> type StartNode<T> = graph.StartNode<T>
local create_node = graph.create_node local create_node = graph.create_node
local create_start_node = graph.create_start_node local create_start_node = graph.create_start_node
local set_owner = graph.set_owner
local track = graph.track local track = graph.track
local update = graph.update local update = graph.update
local get_scope = graph.get_scope local get_scope = graph.get_scope
@ -36,7 +37,6 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
local output_cache = {} :: Map<K, VO> local output_cache = {} :: Map<K, VO>
local input_nodes = {} :: Map<K, StartNode<VI>> local input_nodes = {} :: Map<K, StartNode<VI>>
local remove_queue = {} :: { K } local remove_queue = {} :: { K }
local output_array = {} :: { VO }
local scopes = {} :: Map<K, Scope> local scopes = {} :: Map<K, Scope>
@ -71,8 +71,8 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
local scope = create_node(false) local scope = create_node(false)
scopes[i] = scope scopes[i] = scope
set_owner(scope, owner)
open_scope(scope) open_scope(scope)
track(owner)
local node = create_start_node(v) local node = create_start_node(v)
input_nodes[i] = node input_nodes[i] = node
@ -93,8 +93,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
close_scope() close_scope()
-- output elements local output_array = table.create(#scopes)
table.clear(output_array)
for _, v in next, output_cache do for _, v in next, output_cache do
table.insert(output_array, v) table.insert(output_array, v)
end end
@ -105,7 +104,7 @@ local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI,
local output = create_node(false :: any) local output = create_node(false :: any)
output.effect = function() output.effect = function()
update_children(input()) return update_children(input())
end end
open_scope(output) open_scope(output)
@ -130,7 +129,6 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local output_cache = {} :: Map<VI, VO> local output_cache = {} :: Map<VI, VO>
local input_nodes = {} :: Map<VI, StartNode<K>> local input_nodes = {} :: Map<VI, StartNode<K>>
local output_array = {} :: { VO }
local scopes = {} :: Map<VI, Scope> local scopes = {} :: Map<VI, Scope>
@ -147,7 +145,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
end end
end end
open_scope(root) open_scope(owner)
-- process data -- process data
for i, v in next, data do for i, v in next, data do
@ -159,6 +157,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local scope = create_node(false) local scope = create_node(false)
scopes[v] = scope scopes[v] = scope
set_owner(scope, owner)
open_scope(scope) open_scope(scope)
local node = create_start_node(i) local node = create_start_node(i)
@ -193,13 +192,10 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
table.clear(cur_input_cache) table.clear(cur_input_cache)
cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache
-- output elements local output_array = table.create(#scopes)
table.clear(output_array)
for _, v in next, output_cache do for _, v in next, output_cache do
table.insert(output_array, v) table.insert(output_array, v)
end end
check_primitives(output_array) check_primitives(output_array)
return output_array return output_array
@ -207,7 +203,7 @@ local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () ->
local output = create_node(false :: any) local output = create_node(false :: any)
output.effect = function() output.effect = function()
update_children(input()) return update_children(input())
end end
open_scope(output) open_scope(output)

View file

@ -11,6 +11,8 @@ local close_scope = graph.close_scope
local get_scope = graph.get_scope local get_scope = graph.get_scope
local destroy = graph.destroy local destroy = graph.destroy
local refs = {}
local function root<T>(fn: () -> T): (T, () -> ()) local function root<T>(fn: () -> T): (T, () -> ())
--assert(not get_scope()) --assert(not get_scope())
local node = create_node(false) local node = create_node(false)
@ -21,7 +23,10 @@ local function root<T>(fn: () -> T): (T, () -> ())
close_scope() close_scope()
refs[node] = true
return v, function() return v, function()
refs[node] = nil
destroy(node) destroy(node)
end end
end end

View file

@ -31,7 +31,7 @@ local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local update = graph.update local update = graph.update
local add_child = graph.add_child local set_owner = graph.set_owner
local track = graph.track local track = graph.track
local UPDATE_RATE = 120 local UPDATE_RATE = 120
@ -156,7 +156,7 @@ local function spring<T>(source: () -> T, period: number?, damping_ratio: number
local updater = create_node(false) local updater = create_node(false)
updater.effect = true :: any -- todo updater.effect = true :: any -- todo
add_child(owner, updater) set_owner(updater, owner)
open_scope(updater) open_scope(updater)
local initial_value = source() local initial_value = source()

View file

@ -5,23 +5,21 @@ local create_node = graph.create_node
local get_scope = graph.get_scope local get_scope = graph.get_scope
local open_scope = graph.open_scope local open_scope = graph.open_scope
local close_scope = graph.close_scope local close_scope = graph.close_scope
local add_child = graph.add_child local set_owner = graph.set_owner
local track = graph.track
local function watch(effect: () -> ()) local function watch<T>(effect: (T) -> T, initial_value: T)
local owner = get_scope() local owner = get_scope()
assert(owner) assert(owner)
local node = create_node(false) local node = create_node(false)
node.effect = effect node.effect = effect
add_child(owner, node) set_owner(node, owner)
open_scope(node) open_scope(node)
effect() effect(initial_value)
close_scope() close_scope()
end end
return watch return watch :: (<T>(effect: (T) -> T, initial_value: T) -> ()) & ((effect: () -> ()) -> ())

View file

@ -241,7 +241,6 @@ TEST("graph", function()
CHECK(table.find(get_children(root), scope1 :: Node<any>)) CHECK(table.find(get_children(root), scope1 :: Node<any>))
destroy(scope1) destroy(scope1)
CHECK(table.find(get_children(root), scope1 :: Node<any>))
CHECK(cleaned.scope1) CHECK(cleaned.scope1)
CHECK(cleaned.bind1) CHECK(cleaned.bind1)
scope1 = NIL scope1 = NIL
@ -252,8 +251,6 @@ TEST("graph", function()
CHECK(#get_children(selected) == 1) CHECK(#get_children(selected) == 1)
end end
-- todo: further tests
do CASE "nodes garbage collection" do CASE "nodes garbage collection"
local wref = weak { create_node(1) } local wref = weak { create_node(1) }
destroy(wref[1]) destroy(wref[1])
@ -330,9 +327,11 @@ TEST("source()", wrap_root(function()
end)) end))
TEST("derive()", wrap_root(function() TEST("derive()", wrap_root(function()
local root = vide.root
local source = vide.source local source = vide.source
local derive = vide.derive local derive = vide.derive
local watch = vide.watch local watch = vide.watch
local cleanup = vide.cleanup
do CASE "derive new value on source change" do CASE "derive new value on source change"
local a = source(1) local a = source(1)
@ -367,7 +366,7 @@ TEST("derive()", wrap_root(function()
local num = source(0) local num = source(0)
local is_even = derive(function() local is_even = derive(function()
return num() % 2 == 0 return bit32.band(num(), 0b01) == 0
end) end)
local count = 0 local count = 0
@ -417,15 +416,37 @@ TEST("derive()", wrap_root(function()
CHECK(count == 4) CHECK(count == 4)
end end
do CASE "owner not disconnected"
local count = 0
local a = source(0)
local _, destroy = root(function()
local b = derive(function()
cleanup(function()
count += 1
end)
return a()
end)
end)
CHECK(count == 0)
a(1) -- b clears parents (should not clear owner)
CHECK(count == 1)
destroy()
CHECK(count == 2)
end
do CASE "garbage collection" do CASE "garbage collection"
-- check that `b` does not allow gc of `a` -- check that `b` does not allow gc of `a`
local a = source(1) local a = source(1)
local b = derive(function() local _b = derive(function()
return a() return a()
end) end)
b = NIL _b = NIL
local wref = weak { a } local wref = weak { a }

View file

@ -3,8 +3,8 @@
- better error reporting and stack traces in strict mode - better error reporting and stack traces in strict mode
- auto-enable of strict mode depending on compiler optimizaton level - auto-enable of strict mode depending on compiler optimizaton level
- investigate if weak table iteration can be invalidated - investigate if weak table iteration can be invalidated
- define behavior of `cleanup()` in `untrack()` scopes - property binding optimization
- - would no longer allow `cleanup()` usage in binding scopes
- solution to nested reactivity, see: SolidJS stores - solution to nested reactivity, see: SolidJS stores
- SolidJS control flow components - SolidJS control flow components
- equality checking of derived sources - equality checking of derived sources