diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index e534cde..a46ece7 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -32,100 +32,6 @@ Creates a new source state with the given value. -------------------------------------------------------------------------------- -## derive() - -Derives a new state from existing states. - -- ### Type - - ```lua - function derive(source: () -> T): () -> T - ``` - -- ### Details - - The derived state will have its value recalculated when any source state it - derives from is updated. - - Anytime its value is recalculated it is also cached, subsequent calls will - retun this cached value until it recalculates again. - - Takes a callback that is immediately run to determine what states are being - referenced. - -> ⚠️ Non-yielding. - -- ### Example - - ```lua - local count = wrap(0) - local text = derive(function() return `count: {count()}` end) - - text() -- "count: 0" - - count(1) - - text() -- "count: 1" - ``` - --------------------------------------------------------------------------------- - -## map() - -Maps each value in a table state to a new table state. - -- ### Type - - ```lua - function map( - source: () -> Map, - transform: (value: () -> VI, index: KI) -> VO - ): Map - -- ### Details - - The transform function is called only ever *once* for each index in the - source table. The first argument is a state containing the index's value and - the second argument is just the index. - - Anytime a new index is added, the transform function will be called again for - that new index. - - Anytime an existing index changes, the transform function is not rerun, - instead the passed state for that index will update, causing anything - depending on it to update too. - - Returns a state containing the mapped key-value pairs. - - > ⚠️ Non-yielding. - -- ### Example - - ```lua - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = map(numbers, function(item, i) - return ItemDisplay { - Name = function() - return item().name - end, - - Image = function() - return "rbxassetid://" .. item().icon - end, - - LayoutOrder = i - } - end) - ``` - --------------------------------------------------------------------------------- - ## watch() Runs a callback on state change. @@ -166,3 +72,179 @@ Runs a callback on state change. ``` -------------------------------------------------------------------------------- + +## derive() + +Derives a new state from existing states. + +- ### Type + + ```lua + function derive(source: () -> T): () -> T + ``` + +- ### Details + + The derived state will have its value recalculated when any source state it + derives from is updated. + + Anytime its value is recalculated it is also cached, subsequent calls will + retun this cached value until it recalculates again. + + Takes a callback that is immediately run to determine what states are being + referenced. + +> ⚠️ Non-yielding. + +- ### Example + + ```lua + local count = wrap(0) + local text = derive(function() return `count: {count()}` end) + + text() -- "count: 0" + + count(1) + + text() -- "count: 1" + ``` + +-------------------------------------------------------------------------------- + +## indexes() + +Maps each index in a table to an object. + +- ### Type + + ```lua + function indexes( + source: () -> Map, + transform: (value: () -> VI, index: KI) -> VO + ): Array + +- ### Details + + The transform function is called only ever *once* for each index in the + source table. The first argument is a state containing the index's value and + the second argument is just the index. + + Anytime a new index is added, the transform function will be called again for + that new index. + + Anytime an existing index value changes, the transform function is not rerun, + instead the passed state for that index will update, causing anything + depending on it to update too. + + Returns a state containing an array of all objects returned by the transform. + + > ⚠️ Non-yielding. + +- ### Example + + The intended purpose of this function is to map each index in a table to + a UI element. + + ```lua + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = indexes(numbers, function(item, i) + return ItemDisplay { + Name = function() + return item().name + end, + + Image = function() + return "rbxassetid://" .. item().icon + end, + + LayoutOrder = i + } + end) + ``` + +-------------------------------------------------------------------------------- + +## values() + +Maps each value in a table to an object. + +- ### Type + + ```lua + function values( + source: () -> Map, + transform: (value: VI, index: () -> KI) -> VO + ): Array + +- ### Details + + The transform function is called only ever *once* for each value in the + source table. The first argument is the index's value and + the second argument is a state containing the index. + + Anytime a new value is added, the transform function will be called again + for that new value. + + Anytime an existing value's index changes, the transform function is not + rerun, instead the passed state for that value will update, causing anything + depending on it to update too. + + Returns a state containing an array of all objects returned by the transform. + + > ⚠️ Non-yielding. + +- ### Example + + The intended purpose of this function is to map each value in a table to + a UI element. + + ```lua + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = values(numbers, function(item, i) + return ItemDisplay { + Name = item.Name + + Image = "rbxassetid://" .. item.icon, + + LayoutOrder = i + } + end) + ``` + +- ### Extra + + When should you use `indexes()` and `values()`? + + `values()` should be used when you have a fixed set of objects where the + same objects can be re-arranged in the source table. It maps a value to a + UI element. + + e.g. + - List of all players. + - Inventory of items. + - Chat message history. + - Toast notifications. + + `indexes()` should be used in other cases, especially when your source table + has primitive value. It maps an index to a UI element. + + e.g. + - List of character or weapon stats. + + In most cases, both functions will appear to have the same behavior. + The main difference is performance, picking the right function to use can + result in less property updates and less re-renders. + +-------------------------------------------------------------------------------- diff --git a/docs/tut/crash-course/6-table-state.md b/docs/tut/crash-course/6-table-state.md index 865acd4..c130628 100644 --- a/docs/tut/crash-course/6-table-state.md +++ b/docs/tut/crash-course/6-table-state.md @@ -13,7 +13,7 @@ type Item = { local items = source({} :: Array) List { - Children = map(items, function(item, i) + Children = indexes(items, function(item, i) return create "ImageLabel" { Image = function() return "rbxassetid://" .. item().Icon diff --git a/src/bind.luau b/src/bind.luau index 8819228..a10443a 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -88,32 +88,32 @@ local function bind_parent(instance: Instance, fn: () -> Instance?) end local function bind_children(parent: Instance, fn: () -> { Instance }) - local currentChildrenSet: { [Instance]: true } = {} -- cache of all children parented before update - local newChildrenSet: { [Instance]: true } = {} -- cache of all children parented after 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 setup(parent, function(parent_weak) - local newChildren = fn() -- all (and only) children that should be parented after this update - if newChildren and type(newChildren) ~= "table" then - throw(`Cannot parent instance of type { type(newChildren) } `) + local new_childs = fn() -- all (and only) children that should be parented after this update + if new_childs and type(new_childs) ~= "table" then + throw(`Cannot parent instance of type { type(new_childs) } `) end - if newChildren then - for _, child in next, newChildren do - newChildrenSet[child] = true -- record child set from this update - if not currentChildrenSet[child] then + if new_childs then + for _, child in next, new_childs do + new_child_set[child] = true -- record child set from this update + if not current_child_set[child] then child.Parent = parent_weak -- if child wasn't already parented then parent it else - currentChildrenSet[child] = nil -- remove child from cache if it was already in cache + current_child_set[child] = nil -- remove child from cache if it was already in cache end end end - for child in next, currentChildrenSet do + for child in next, current_child_set do child.Parent = nil -- unparent all children that weren't in the new children set end - table.clear(currentChildrenSet) -- clear cache, preserve capacity - currentChildrenSet, newChildrenSet = newChildrenSet, currentChildrenSet + table.clear(current_child_set) -- clear cache, preserve capacity + current_child_set, new_child_set = new_child_set, current_child_set end) end diff --git a/src/map.luau b/src/map.luau deleted file mode 100644 index ebf9530..0000000 --- a/src/map.luau +++ /dev/null @@ -1,65 +0,0 @@ -if not game then script = (require :: any) "test/wrap-require" end - -local graph = require(script.Parent.graph) -type Node = graph.Node -local create = graph.create -local set = graph.set -local capture = graph.capture -local link = graph.link - -type Map = { [K]: V } - -local function map(input: () -> Map, transform: (() -> VI, K) -> VO): () -> Map - local input_cache = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local remove_queue = {} :: { K } - - local function recompute(data) - -- queue removed values - for k in next, input_cache do - if data[k] == nil then - table.insert(remove_queue, k) - end - end - - -- remove queued values - for _, k in next, remove_queue do - input_cache[k] = nil - output_cache[k] = nil - input_nodes[k] = nil - end - - -- process new or changed values - for k, v in next, data do - if input_cache[k] == nil then - local node, get_value = create(v) - input_nodes[k] = node - output_cache[k] = transform(get_value, k) - elseif input_cache[k] ~= v then - set(input_nodes[k], v) - end - input_cache[k] = v - end - - return output_cache - end - - local function derive() - return recompute(input()) - end - - local output, output_get = create(output_cache) - - local nodes, value = capture(input) - - for _, node in next, nodes do - link(node, output, derive) - end - - output.cache = recompute(value) - - return output_get -end - -return map diff --git a/src/maps.luau b/src/maps.luau index 2917166..95f3aef 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -9,13 +9,13 @@ local link = graph.link type Map = { [K]: V } --- todo: optimize, double buffering? - +-- todo: this could be optimized local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } local input_cache = {} :: Map local output_cache = {} :: Map local input_nodes = {} :: Map> local remove_queue = {} :: { K } + local output_array = {} :: { VO } local function recompute(data) -- queue removed values @@ -34,23 +34,25 @@ local function indexes(input: () -> Map, transform: (() -> VI, -- process new or changed values for k, v in next, data do - if input_cache[k] == nil then + local cv = input_cache[k] + if cv == nil then local node, get_value = create(v) input_nodes[k] = node output_cache[k] = transform(get_value, k) - elseif input_cache[k] ~= v then + input_cache[k] = v + elseif cv ~= v then set(input_nodes[k], v) + input_cache[k] = v end - input_cache[k] = v end - local output = {} - + -- output elements + table.clear(output_array) for _, v in next, output_cache do - table.insert(output, v) + table.insert(output_array, v) end - return output + return output_array end local function derive() @@ -70,50 +72,54 @@ local function indexes(input: () -> Map, transform: (() -> VI, return output_get end +-- todo: this should be optimized local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local input_cache = {} :: Map + local input_cache_up = {} :: Map + local input_cache_buffer_up = {} :: Map + local output_cache = {} :: Map local input_nodes = {} :: Map> - local remove_queue = {} :: { VI } + local output_array = {} :: { VO } local function recompute(data: Map) - local inverted_data = {} + local input_cache, input_cache_buffer = + input_cache_up, input_cache_buffer_up -- process new or changed values for i, v in next, data do - if input_cache[v] == nil then + local cv = input_cache[v] + + if cv == nil then local node, get_value = create(i) input_nodes[v] = node - input_cache[v] = i output_cache[v] = transform(v, get_value) - elseif input_cache[v] ~= i then + elseif cv ~= i then set(input_nodes[v], i) end - - inverted_data[v] = i + + input_cache_buffer[v] = i end - -- queue removed values + -- remove old values for v, k in next, input_cache do - if inverted_data[v] == nil then - table.insert(remove_queue, v) + if input_cache_buffer[v] == nil then + output_cache[v] = nil + input_nodes[v] = nil end end - -- remove queued values - for _, k in next, remove_queue do - input_cache[k] = nil - output_cache[k] = nil - input_nodes[k] = nil - end + -- update buffer cache + table.clear(input_cache) + input_cache_up, input_cache_buffer_up = input_cache_buffer, input_cache - local output = {} + -- output elements + table.clear(output_array) for _, v in next, output_cache do - table.insert(output, v) + table.insert(output_array, v) end - return output + return output_array end local function derive() diff --git a/src/spring.luau b/src/spring.luau index bcd7168..9b73d08 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -176,9 +176,9 @@ local function update_springs(dt: number) set(output, value) - if new_alpha > 0.9 then - table.insert(remove_queue, data) - end + -- if new_alpha > 0.9 then + -- table.insert(remove_queue, data) + -- end end for _, data in next, remove_queue do diff --git a/test/benchmark.luau b/test/benchmark.luau index 5ab30b5..6264e84 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -6,7 +6,7 @@ local BENCH, START = require("test/testkit").benchmark() local vide = require "src/init" -local N = 1e6 +local N = 2^18 -- 262144 BENCH("Create state", function() local cache = table.create(N) @@ -17,7 +17,7 @@ BENCH("Create state", function() end end) -BENCH("Get state value", function() +BENCH("Get value", function() local state = vide.source(1) for i = 1, START(N) do @@ -25,7 +25,7 @@ BENCH("Get state value", function() end end) -BENCH("Set state value", function() +BENCH("Set value", function() local state = vide.source(1) for i = 1, START(N) do @@ -45,20 +45,22 @@ BENCH("Derive 1 state", function() end end) -BENCH("Derive 2 states", function() +BENCH("Derive 4 states", function() 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 derive = vide.derive for i = 1, START(N) do cache[i] = derive(function() - return state() + state2() + return state() + state2() + state3() + state4() end) end end) -BENCH("Set state value derived", function() +BENCH("Set derived value", function() local state = vide.source(1) local _derived = vide.derive(state) @@ -67,7 +69,16 @@ BENCH("Set state value derived", function() end end) -BENCH("Apply 4 properties", function() +BENCH("Apply 0 properties", function() + local apply = require "src/apply" + local instance = vide.create("Frame") {} + + for i = 1, START(N) do + apply(instance, {}) + end +end) + +BENCH("Apply 8 properties", function() local apply = require "src/apply" local instance = vide.create("Frame") {} @@ -76,7 +87,11 @@ BENCH("Apply 4 properties", function() Name = i, Name2 = i, Name3 = i, - Name4 = i + Name4 = i, + Name5 = i, + Name6 = i, + Name7 = i, + Name8 = i, }) end end) @@ -93,4 +108,99 @@ BENCH("Bind state", function() end end) +BENCH("Update binding", function() + local apply = require "src/apply" + local instance = vide.create("Frame") {} + local state = vide.source(1) + + apply(instance, { + Name = state + }) + + for i = 1, START(N) do + state(i) + end +end) + +BENCH("indexes() no change", function() + local data = {} + + for i = 1, N do + data[i] = i + end + + local state = vide.source(data) + + local _list = vide.values(state, function(v, i) + return {} + end) + + START(N) + + state(data) +end) + +BENCH("indexes() all change", function() + local data = {} + + for i = 1, N do + data[i] = i + end + + local state = vide.source(data) + + local _list = vide.values(state, function(v, i) + return {} + end) + + for i, v in data do + data[i] = v + 1 + end + + START(N) + + state(data) +end) + +BENCH("values() no change", function() + local data = {} + + for i = 1, N do + data[i] = {} + end + + local state = vide.source(data) + + local _list = vide.values(state, function(v, i) + return {} + end) + + START(N) + + state(data) +end) + +BENCH("values() all change", function() + local data = {} + + for i = 1, N do + data[i] = {} + end + + local state = vide.source(data) + + local _list = vide.values(state, function(v, i) + return {} + end) + + for i = 1, N do + local r = math.random(1, #data) + data[i], data[r] = data[r], data[i] + end + + START(N) + + state(data) +end) + return nil diff --git a/test/tests.luau b/test/tests.luau index 8b0c6b0..9a06fec 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1054,15 +1054,33 @@ TEST("values()", function() local input = source { 1, 2, 3 } local output = values(input, function(v, i) - return v + return { v = v, i = i } end) input { 1, 2 } local t = output() - CHECK(t[1] == 1) - CHECK(t[2] == 2) + CHECK(t[1].v == 1) + CHECK(t[2].v == 2) + CHECK(t[3] == nil) + end + + do CASE "Removal reflected 2" + local input = source { 1 } + + local output = values(input, function(v, i) + return { v = v, i = i } + end) + + input { 2, 1 } + input { 1 } + + local t = output() + + CHECK(t[1].v == 1) + CHECK(t[1].i() == 1) + CHECK(t[2] == nil) CHECK(t[3] == nil) end end)