diff --git a/src/action.luau b/src/action.luau index 88f4a22..3efcfe6 100644 --- a/src/action.luau +++ b/src/action.luau @@ -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() diff --git a/src/apply.luau b/src/apply.luau index 960ff8b..d0bccf7 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -10,11 +10,16 @@ local _, is_action = require(script.Parent.action)() local graph = require(script.Parent.graph) type Node = graph.Node +type Array = { V } +type Map = { [K]: V } + -- buffer of event -> callback to connect after properties are set -local event_buffer: { [string]: () -> () } = {} +local event_buffer = {} :: Map ()> -- buffer of priority -> callback to run after events are connected -local action_buffers = {} :: { { (Instance) -> () } } +local action_buffers = {} :: Map ()>> + +-- 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> + 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) 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) -- 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(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) diff --git a/src/bind.luau b/src/bind.luau index c0328d9..99718a5 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,5 +1,4 @@ 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) @@ -33,27 +32,38 @@ local function traceback(skips: number) -- ensures trace begins outside of any v return debug.traceback(nil, s) end -function create_binding(updater: (T) -> T, binding_data: 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 - -- end - -- end +function create_binding(updater: (T) -> T, binding: T) + if flags.strict then + -- track bind creation trace + local fn = updater + local bind_trace = traceback(0) + updater = function(...) + local ok, result = xpcall(fn, function(err: string) + return err + end, ...) - local binding = create_node(binding_data, updater) + 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 + + local owner = get_scope() - if not owner then throw("cannot bind property in non-reactive scope") end - assert(owner) + if not owner then + throw("cannot bind property in non-reactive scope") + end; assert(owner) + + local node = create_node(binding, updater) - set_owner(binding, owner) - evaluate_node(binding) + set_owner(node, owner) + evaluate_node(node) end type PropertyBinding = { @@ -81,7 +91,7 @@ type ChildrenBinding = { instance: Instance, cur_children_set: { [Instance]: true }, new_children_set: { [Instance]: true }, - children: () -> { Instance } + children: () -> Instance | { Instance } } local function update_children(p: ChildrenBinding) @@ -95,7 +105,7 @@ local function update_children(p: ChildrenBinding) end if new_children then - for _, child in next, new_children do + 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 diff --git a/src/derive.luau b/src/derive.luau index d5bbb7f..7c2a763 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -10,8 +10,9 @@ local evaluate_node = graph.evaluate_node local function derive(fn: () -> T): () -> T local owner = get_scope() - if not owner then throw("cannot derive in non-reactive scope") end - assert(owner) + if not owner then + throw("cannot derive in non-reactive scope") + end; assert(owner) local node = create_node(false :: any, fn) diff --git a/src/graph.luau b/src/graph.luau index 2b939e6..249d2f6 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -5,26 +5,24 @@ local flags = require(script.Parent.flags) export type StartNode = { cache: T, - children: { Node } | false + [number]: Node } export type Node = { cache: T, - owner: Node | false, - parents: { StartNode }, - children: { Node } | false, effect: ((T) -> T) | false, + + owner: Node | false, cleanups: { () -> () } | false, + + parents: { StartNode }, + [number]: Node } local scopes = { n = 0 } :: { [number]: Node, n: number } -local WEAK_VALUES = { __mode = "v" } -local WEAK_KEYS = { __mode = "k" } -local EVALUATION_ERR = "error while evaluating source:\n\n" - -- runs a given callback in a context that Luau does not allow yielding in -local check_for_yield: (fn: (T...) -> unknown, T...) -> () do +local check_for_yield: (fn: (T...) -> (boolean, string?), T...) -> () do local t = { __mode = "kv" } setmetatable(t, t) @@ -35,17 +33,13 @@ local check_for_yield: (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 effecter") - 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 @@ -57,24 +51,14 @@ local function get_scope(): Node? return scopes[scopes.n] end - local function add_child(parent: StartNode, child: Node) - if parent.children then - table.insert(parent.children :: { Node }, child) - else - parent.children = { child } - end - + table.insert(parent, child) table.insert(child.parents, parent) end local function set_owner(node: Node, owner: Node) node.owner = owner - if owner.children then - table.insert(owner.children :: { Node }, node) - else - owner.children = { node } - end + table.insert(owner, node) end local function open_scope(node: Node) @@ -108,12 +92,11 @@ local function run_cleanups(node: Node) end local function remove_child(parent: StartNode, child: Node) - local children = parent.children :: {} - local idx = table.find(children :: {}, child) - - local n = #children - children[idx] = children[n] - children[n] = nil + 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(node: Node) @@ -127,12 +110,13 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) - if node.owner then remove_child(node.owner, node) end - node.owner = false - local children = node.children :: {} - if children then - while children[1] do destroy(children[1]) end + + if node.owner then + remove_child(node.owner, node) + node.owner = false end + + while node[1] do destroy(node[1]) end end local function evaluate_node(node: Node) @@ -147,7 +131,7 @@ local function evaluate_node(node: Node) close_scope() if not ok then - throw(`side-effect error\n{new_value}`) + throw(`side-effect error from source update\n{new_value}`) end node.cache = new_value @@ -158,8 +142,7 @@ end local update_queue = {} :: { Node } local function update(node: StartNode) - local children = node.children :: {} - if not children then return end + if not node[1] then return end local n0 = #update_queue local first_update = n0 == 0 @@ -170,14 +153,14 @@ local function update(node: StartNode) end do - local child = children[1] + local child = node[1] while child do -- todo: case where child in owner context unparent(child) n += 1 update_queue[n] = child - child = children[1] + child = node[1] end end @@ -216,15 +199,7 @@ local function create_node(value: T, effect: false | (T) -> T): Node end local function get_children(node: Node): { Node } - if not node.children then return {} end - - local children = {} - - for _, child in node.children do - table.insert(children, child) - end - - return children :: { Node } + return { unpack(node) } :: { Node } end local function create_start_node(value: T): StartNode diff --git a/src/maps.luau b/src/maps.luau index ffbfa7e..db93d04 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -15,6 +15,7 @@ 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 } @@ -33,14 +34,17 @@ end -- todo: optimize output array local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } local owner = get_scope() - if not owner then throw("cannot derive in non-reactive scope") end - assert(owner) + 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 local output_cache = {} :: Map local input_nodes = {} :: Map> local remove_queue = {} :: { K } - local scopes = {} :: Map> local function update_children(data) @@ -63,7 +67,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, table.clear(remove_queue) - open_scope(owner) -- todo: needed? + open_scope(subowner) -- process new or changed values for i, v in next, data do @@ -71,10 +75,10 @@ local function indexes(input: () -> Map, transform: (() -> VI, if cv ~= v then if cv == nil then - local scope = create_node(false) + local scope = create_node(false, false) scopes[i] = scope :: Node - set_owner(scope, owner) + set_owner(scope, subowner) open_scope(scope) local node = create_start_node(v) @@ -125,15 +129,17 @@ end -- todo: optimize output array local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } local owner = get_scope() - if not owner then throw("cannot derive in non-reactive scope") end - assert(owner) + 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 local new_input_cache_up = {} :: Map - local output_cache = {} :: Map local input_nodes = {} :: Map> - local scopes = {} :: Map> local function update_children(data: Map) @@ -149,7 +155,7 @@ local function values(input: () -> Map, transform: (VI, () -> end end - open_scope(owner) + open_scope(subowner) -- process data for i, v in next, data do @@ -158,10 +164,10 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] if cv == nil then - local scope = create_node(false) + local scope = create_node(false, false) scopes[v] = scope :: Node - set_owner(scope, owner) + set_owner(scope, subowner) open_scope(scope) local node = create_start_node(i) diff --git a/test/benchmark.luau b/test/benchmark.luau index 59a54b5..a5dec70 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -4,7 +4,9 @@ local vide = require "src/init" local N = 2^18 -- 262144 -BENCH("create state", function() +-- todo: wide and deep graph benchmarks + +BENCH("create source", function() local source = vide.source local cache = table.create(N) @@ -15,50 +17,50 @@ BENCH("create state", function() end) BENCH("get value", function() - local state = vide.source(1) + local src = vide.source(1) for i = 1, START(N) do - state() + src() end end) BENCH("set value", function() - local state = vide.source(1) + local src = vide.source(1) for i = 1, START(N) do - state(i) + src(i) end end) -BENCH("derive 1 state", function() +BENCH("derive 1 source", function() local derive = vide.derive local cache = table.create(N) - local state = vide.source(1) + local src = vide.source(1) vide.root(function() for i = 1, START(N) do cache[i] = derive(function() - return state() + return src() end) end return nil end) end) -BENCH("derive 4 states", function() +BENCH("derive 4 sources", function() local derive = vide.derive local cache = table.create(N) - local state = vide.source(1) - local state2 = vide.source(2) - local state3 = vide.source(3) - local state4 = vide.source(4) + local src = vide.source(1) + local src2 = vide.source(2) + local src3 = vide.source(3) + local src4 = vide.source(4) vide.root(function() for i = 1, START(N) do cache[i] = derive(function() - return state() + state2() + state3() + state4() + return src() + src2() + src3() + src4() end) end @@ -66,14 +68,15 @@ BENCH("derive 4 states", function() end) end) +-- todo: why is this so fast? BENCH("set derived value", function() - local state = vide.source(1) + local src = vide.source(1) vide.root(function() - local _derived = vide.derive(state) + local _derived = vide.derive(src) for i = 1, START(N) do - state(i) + src(i) end return nil @@ -95,14 +98,14 @@ BENCH("apply 8 properties", function() for i = 1, START(N) do apply(instance, { - Name = i, - Name2 = i, - Name3 = i, - Name4 = i, - Name5 = i, - Name6 = i, - Name7 = i, - Name8 = i, + Text = i, + Text2 = i, + Text3 = i, + Text4 = i, + Text5 = i, + Text6 = i, + Text7 = i, + Text8 = i, }) end end) @@ -111,12 +114,12 @@ BENCH("bind source", function() local apply = require "src/apply" local instance = vide.create("Frame") {} - local state = vide.source(1) + local src = vide.source(1) vide.root(function() for i = 1, START(N) do apply(instance, { - Name = state + Text = src }) end @@ -128,15 +131,15 @@ BENCH("update binding", function() local apply = require "src/apply" local instance = vide.create("Frame") {} - local state = vide.source(1) + local src = vide.source(1) vide.root(function() apply(instance, { - Name = state + Text = src }) for i = 1, START(N) do - state(i) + src(i) end return nil @@ -145,6 +148,26 @@ end) N /= 1024 +BENCH("indexes() all new", function() + local data = {} + + for i = 1, N do + data[i] = i + end + + local src = vide.source(data) + + vide.root(function() + START(N) + + local _list = vide.indexes(src, function(v, i) + return {} + end) + + return nil + end) +end) + BENCH("indexes() no change", function() local data = {} @@ -152,18 +175,16 @@ BENCH("indexes() no change", function() data[i] = i end - local state = vide.source(data) + local src = vide.source(data) vide.root(function() - local _list = vide.indexes(state, function(v, i) + local _list = vide.indexes(src, function(v, i) return {} end) - --state(state()) -- fill double buffer - START(N) - state(data) + src(data) return nil end) @@ -176,15 +197,15 @@ BENCH("indexes() all change", function() data[i] = i end - local state = vide.source(data) + local src = vide.source(data) vide.root(function() - local _list = vide.indexes(state, function(v, i) + local _list = vide.indexes(src, function(v, i) return {} end) - --state(state()) -- fill double buffer + --src(src()) -- fill double buffer for i, v in data do data[i] = v + 1 @@ -192,7 +213,7 @@ BENCH("indexes() all change", function() START(N) - state(data) + src(data) return nil end) @@ -205,10 +226,10 @@ BENCH("indexes() all remove", function() data[i] = i end - local state = vide.source(data) + local src = vide.source(data) vide.root(function() - local _list = vide.indexes(state, function(v, i) + local _list = vide.indexes(src, function(v, i) return {} end) @@ -216,7 +237,27 @@ BENCH("indexes() all remove", function() START(N) - state(data) + src(data) + + return nil + end) +end) + +BENCH("values() all new", function() + local data = {} + + for i = 1, N do + data[i] = {} + end + + local src = vide.source(data) + + vide.root(function() + START(N) + + local _list = vide.values(src, function(v, i) + return {} + end) return nil end) @@ -229,18 +270,18 @@ BENCH("values() no change", function() data[i] = {} end - local state = vide.source(data) + local src = vide.source(data) vide.root(function() - local _list = vide.values(state, function(v, i) + local _list = vide.values(src, function(v, i) return {} end) - state(state()) -- fill double buffer + src(src()) -- fill double buffer START(N) - state(data) + src(data) return nil end) @@ -253,14 +294,14 @@ BENCH("values() all change", function() data[i] = {} end - local state = vide.source(data) + local src = vide.source(data) vide.root(function() - local _list = vide.values(state, function(v, i) + local _list = vide.values(src, function(v, i) return {} end) - state(state()) -- fill double buffer + src(src()) -- fill double buffer for i = 1, N do local r = math.random(1, #data) @@ -269,7 +310,7 @@ BENCH("values() all change", function() START(N) - state(data) + src(data) return nil end) @@ -282,10 +323,10 @@ BENCH("values() all remove", function() data[i] = {} end - local state = vide.source(data) + local src = vide.source(data) vide.root(function() - local _list = vide.values(state, function(v, i) + local _list = vide.values(src, function(v, i) return {} end) @@ -293,7 +334,7 @@ BENCH("values() all remove", function() START(N) - state(data) + src(data) return nil end) diff --git a/test/mock.luau b/test/mock.luau index 0c76dcb..1dfb9bc 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -113,6 +113,7 @@ local Instance = {} :: any do local function __newindex(userdata: userdata, property: string, value: unknown) local data = get_data(userdata) if property == "Name" then + if type(value) ~= "string" then error("name must be a string", 2) end data.name = value :: string elseif property == "Parent" then assert(value == nil or is_instance(value), "attempt to set non-instance as parent") diff --git a/test/tests.luau b/test/tests.luau index b8e0394..45e0ed9 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -29,15 +29,7 @@ local function wrap_root(fn: () -> ()) end end -local NIL = nil - --- vide.mount(function() --- local src = vide.source(0) - --- vide.effect(function() --- axasd += 1 --- end) --- end) +local NIL = nil :: any TEST("graph", function() local create_node = graph.create_node