From da85cbbac85f7b0040812ab3ab4c62ee1eae467d Mon Sep 17 00:00:00 2001 From: Alo <70312917+Aloroid@users.noreply.github.com> Date: Sat, 7 Oct 2023 00:16:01 +0200 Subject: [PATCH 01/94] Fix docs typo minor spelling mistake --- docs/api/creation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/creation.md b/docs/api/creation.md index b4576b8..8e67478 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -69,7 +69,7 @@ Creates a new UI element, applying any given properties. - **index is number:** - **value is action:** run action - **value is table:** recurse table - - **value is functon:** create effect to update children + - **value is function:** create effect to update children - **value is instance:** set instance as child - **Example** From 8218e4702fc525e5491ea68d0479458cd867cd17 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 25 Oct 2023 15:49:35 +0100 Subject: [PATCH 02/94] Fix diamond graphs --- src/graph.luau | 83 +++++++++++++++++++++++++++++++++--------------- test/tests.luau | 84 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index bb42fbb..0c0bf21 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -181,47 +181,80 @@ local function evaluate_node(node: Node) return cur_value ~= new_value -- node has changed value end -local function update_from(node: StartNode, n0: number) - if not node[1] then return end +-- local function update_from(node: StartNode, n0: number) +-- if not node[1] then return end - local n = n0 +-- local n = n0 - -- unparent all children and queue for eval - do - local child = node[1] - while child do - --assert(child.parents.owner) - unparent(child) - n += 1 - update_queue[n] = child - child = node[1] - end +-- -- unparent all children and queue for eval +-- do +-- local child = node[1] +-- while child do +-- --assert(child.parents.owner) +-- unparent(child) +-- n += 1 +-- update_queue[n] = child +-- child = node[1] +-- end +-- end + +-- update_queue.n = n + +-- -- evaluate all queued children +-- for i = n0 + 1, n do +-- local child = update_queue[i] +-- assert(type(child.effect) == "function") + +-- if evaluate_node(child) then +-- update_from(child, n) +-- end + +-- update_queue[i] = false :: any -- false instead of nil to avoid sparse +-- end + +-- update_queue.n = n0 +-- end + +-- local function update(node: StartNode) +-- update_from(node, update_queue.n) +-- end + +local function queue_children(node: StartNode) + local i = update_queue.n + local child = node[1] + while child do + --assert(child.parents.owner) + unparent(child) + i += 1 + update_queue[i] = child + child = node[1] end + update_queue.n = i +end - update_queue.n = n +local function update(root: StartNode) + local n0 = update_queue.n + queue_children(root) - -- evaluate all queued children - for i = n0 + 1, n do - local child = update_queue[i] - assert(type(child.effect) == "function") + local i = n0 + 1 + while i <= update_queue.n do + local node = update_queue[i] + assert(node.effect) - if evaluate_node(child) then - update_from(child, n) + if evaluate_node(node) then + queue_children(node) end update_queue[i] = false :: any -- false instead of nil to avoid sparse + i += 1 end update_queue.n = n0 end -local function update(node: StartNode) - update_from(node, update_queue.n) -end - local function track(node: StartNode) local scope = get_scope() - if scope and type(scope.effect) == "function" then -- do not track nodes with no effect + if scope and scope.effect then -- do not track nodes with no effect add_child(node, scope) end end diff --git a/test/tests.luau b/test/tests.luau index a82712b..0c72f90 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,5 +1,5 @@ local testkit = require("test/testkit") -local TEST, CASE, CHECK, FINISH = testkit.test() +local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() local mock = require "test/mock" local Instance, Signal = mock.Instance, mock.Signal @@ -33,6 +33,8 @@ local NIL = nil :: any vide.strict = false +--SKIP "graph edge cases" + TEST("graph", function() local create_node = graph.create_node local track = graph.track @@ -1907,7 +1909,85 @@ TEST("nested effects cases", function() root(App) end) -vide.strict = true +TEST("graph edge cases", wrap_root(function() + local source = vide.source + local derive = vide.derive + local effect = vide.effect + + do CASE "diamond A,B,C,D" + --[[ + + a > b > d + > c > + + ]] + + local a = source(0) + + local b = derive(function() return (a() % 2 == 0) and 1 or 0 end) + local c = derive(function() return a() * 2 end) + local d = derive(function() return b() + c() end) + + local count = { b = 0, c = 0, d = 0 } + effect(function() b(); count.b += 1 end) + effect(function() c(); count.c += 1 end) + effect(function() d(); count.d += 1 end) + + a(1) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(d() == 2) + + a(3) + CHECK(count.b == 2) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(d() == 6) + end + + do CASE "diamond A,B,C,D,E" + --[[ + + a > b > > e + > c > d > + + ]] + + local a = source(0) + + local b = derive(function() print "ran b"; return (a() % 2 == 0) and 1 or 0 end) + local c = derive(function() print "ran c"; return a() * 2 end) + local d = derive(function() print "ran d"; return c() * 2 end) + local e = derive(function() print "ran e"; return b() + c() end) + + local count = { b = 0, c = 0, d = 0, e = 0 } + effect(function() b(); count.b += 1 end) + effect(function() c(); count.c += 1 end) + effect(function() d(); count.d += 1 end) + effect(function() d(); count.e += 1 end) + + a(1) + + print(e()) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(count.e == 2) + CHECK(e() == 4) -- todo: solve e evaluating before d + + a(3) + CHECK(count.b == 2) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(count.e == 3) + CHECK(e() == 12) + end + + do CASE "repeated read" + + end +end)) TEST("strict", wrap_root(function() vide.strict = true From 15255855c3bad0f0af75fdf84297cdbc005a8ead Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:58:29 +0100 Subject: [PATCH 03/94] Add repeated read test --- src/graph.luau | 38 -------------------------------------- test/tests.luau | 9 ++++++++- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index 0c0bf21..ab0be0b 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -181,44 +181,6 @@ local function evaluate_node(node: Node) return cur_value ~= new_value -- node has changed value end --- local function update_from(node: StartNode, n0: number) --- if not node[1] then return end - --- local n = n0 - --- -- unparent all children and queue for eval --- do --- local child = node[1] --- while child do --- --assert(child.parents.owner) --- unparent(child) --- n += 1 --- update_queue[n] = child --- child = node[1] --- end --- end - --- update_queue.n = n - --- -- evaluate all queued children --- for i = n0 + 1, n do --- local child = update_queue[i] --- assert(type(child.effect) == "function") - --- if evaluate_node(child) then --- update_from(child, n) --- end - --- update_queue[i] = false :: any -- false instead of nil to avoid sparse --- end - --- update_queue.n = n0 --- end - --- local function update(node: StartNode) --- update_from(node, update_queue.n) --- end - local function queue_children(node: StartNode) local i = update_queue.n local child = node[1] diff --git a/test/tests.luau b/test/tests.luau index 0c72f90..b431a90 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1969,7 +1969,6 @@ TEST("graph edge cases", wrap_root(function() a(1) - print(e()) CHECK(count.b == 2) CHECK(count.c == 2) CHECK(count.d == 2) @@ -1985,7 +1984,15 @@ TEST("graph edge cases", wrap_root(function() end do CASE "repeated read" + local a = source(0) + local b = derive(function() return a() + a() end) + local count = 0 + effect(function() b(); count += 1 end) + + a(1) + CHECK(b() == 2) + CHECK(count == 2) end end)) From aca08709b6ecaee21dbb0586d659e95eb20fd800 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 25 Oct 2023 20:02:40 +0100 Subject: [PATCH 04/94] Update tests --- test/benchmark.luau | 7 ++++--- test/tests.luau | 31 ------------------------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/test/benchmark.luau b/test/benchmark.luau index f3c39b6..aac9071 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -132,22 +132,23 @@ ROOT_BENCH("update 1->1->1->1...1000 graph", function() end end) +-- todo: crashes at 1k -- todo: repeat with batching ROOT_BENCH("update 1000->1 graph", function() local srcs = {} - for i = 1, 1000 do + for i = 1, 800 do srcs[i] = source(0) end derive(function() - for i = 1, 1000 do + for i = 1, 800 do srcs[i]() end return false end) for i = 1, START(1) do - for idx = 1, 1000 do + for idx = 1, 800 do srcs[idx](i) end end diff --git a/test/tests.luau b/test/tests.luau index b431a90..0f22371 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -136,37 +136,6 @@ TEST("graph", function() CHECK(d_cnt == 1) end - do CASE "diamond graph 2" - -- todo: include cached value from parent nodes to confirm update order - -- a -> b -> c -> e - -- -> d - local root = node() - local a, b, c, d, e = node(), node(), node(), node(), node() - - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) - set_owner(e, root) - - local b_cnt, c_cnt, d_cnt, e_cnt = 0, 0, 0, 0 - function b.effect(x) b_cnt += 1; return not x end - function c.effect(x) c_cnt += 1; return not x end - function d.effect(x) d_cnt += 1; return not x end - function e.effect(x) e_cnt += 1; return not x end - - open_scope(b); track(a); close_scope() - open_scope(c); track(b); close_scope() - open_scope(d); track(a); close_scope() - open_scope(e); track(c); track(d); close_scope() - - update(a) - - CHECK(b_cnt == 1) - CHECK(c_cnt == 1) - CHECK(d_cnt == 1) - CHECK(e_cnt == 1) - end - do CASE "duplicate child on rerun" local root = node() local a, b, c = node(), node(), node() From 7d82fe353e1d0865f5ef267e02b5b2056e3e61e3 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Fri, 27 Oct 2023 15:47:43 +0100 Subject: [PATCH 05/94] Fix error in test Also misc changes --- src/graph.luau | 33 ++++++++++++++++----------------- src/spring.luau | 3 ++- test/tests.luau | 24 ++++++++++++------------ todo.md | 10 ++++------ 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/graph.luau b/src/graph.luau index ab0be0b..d316e78 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -42,12 +42,14 @@ end local function get_owning_scope(): Node local scope = get_scope() + if not scope then local caller_name = debug.info(2, "n") return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) elseif scope.effect then - throw("reactive scope is not an owning scope; new effects cannot be created in side-effects") + throw("cannot create new reactive scope inside of a tracking scope") -- todo: allow this? end + return scope end @@ -96,8 +98,8 @@ local function run_cleanups(node: Node) end local function find_and_swap_pop(t: { T }, v: T) - local idx = table.find(t, v) - assert(idx, "value not found") + local idx = table.find(t, v) :: number + --assert(idx, "value not found") local n = #t t[idx] = t[n] t[n] = nil @@ -107,10 +109,9 @@ local function remove_child(parent: StartNode, child: Node) find_and_swap_pop(parent, child) end -local function remove_owner(node: Node) - local owner = node.owner :: Node - if node.owner and owner.owned then - find_and_swap_pop(owner.owned, node) +local function disown(node: Node) + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) end end @@ -126,7 +127,7 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) - remove_owner(node) + disown(node) if node.owned then local owned = node.owned @@ -137,7 +138,8 @@ end local function destroy_owned(node: Node) if node.owned then - while node.owned[1] do destroy(node.owned[1]) end + local owned = node.owned + while owned[1] do destroy(owned[1]) end end end @@ -178,18 +180,15 @@ local function evaluate_node(node: Node) node.cache = new_value - return cur_value ~= new_value -- node has changed value + return cur_value ~= new_value end local function queue_children(node: StartNode) local i = update_queue.n - local child = node[1] - while child do - --assert(child.parents.owner) - unparent(child) + while node[1] do i += 1 - update_queue[i] = child - child = node[1] + update_queue[i] = node[1] + unparent(node[1]) end update_queue.n = i end @@ -201,7 +200,7 @@ local function update(root: StartNode) local i = n0 + 1 while i <= update_queue.n do local node = update_queue[i] - assert(node.effect) + --assert(node.effect) if evaluate_node(node) then queue_children(node) diff --git a/src/spring.luau b/src/spring.luau index 66dc896..b627a74 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -161,7 +161,8 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local c_c = 2*w_n local c = z * c_c - -- todo: is there a solution to this other than upping step frequency? + -- todo: is there a solution other than reducing step size? + -- todo: this does not catch all solver exploding cases if c > UPDATE_RATE*2 then -- solver will explode if this is true throw("spring damping too high, consider reducing damping or increasing period") end diff --git a/test/tests.luau b/test/tests.luau index 0f22371..5c51e5f 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,5 +1,5 @@ local testkit = require("test/testkit") -local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() +local TEST, CASE, CHECK, FINISH = testkit.test() local mock = require "test/mock" local Instance, Signal = mock.Instance, mock.Signal @@ -33,8 +33,6 @@ local NIL = nil :: any vide.strict = false ---SKIP "graph edge cases" - TEST("graph", function() local create_node = graph.create_node local track = graph.track @@ -1918,37 +1916,39 @@ TEST("graph edge cases", wrap_root(function() do CASE "diamond A,B,C,D,E" --[[ - a > b > > e + a > b > e > c > d > ]] local a = source(0) - local b = derive(function() print "ran b"; return (a() % 2 == 0) and 1 or 0 end) - local c = derive(function() print "ran c"; return a() * 2 end) - local d = derive(function() print "ran d"; return c() * 2 end) - local e = derive(function() print "ran e"; return b() + c() end) + local b = derive(function() return (a() % 2 == 0) and 1 or 0 end) + local c = derive(function() return a() * 2 end) + local d = derive(function() return c() * 2 end) + local e = derive(function() return b() + d() end) local count = { b = 0, c = 0, d = 0, e = 0 } effect(function() b(); count.b += 1 end) effect(function() c(); count.c += 1 end) effect(function() d(); count.d += 1 end) - effect(function() d(); count.e += 1 end) + effect(function() e(); count.e += 1 end) + + CHECK(e() == 1) a(1) CHECK(count.b == 2) CHECK(count.c == 2) CHECK(count.d == 2) - CHECK(count.e == 2) - CHECK(e() == 4) -- todo: solve e evaluating before d + CHECK(count.e == 3) -- todo: redundant re-eval + CHECK(e() == 4) a(3) CHECK(count.b == 2) CHECK(count.c == 3) CHECK(count.d == 3) - CHECK(count.e == 3) + CHECK(count.e == 4) CHECK(e() == 12) end diff --git a/todo.md b/todo.md index 8daf7a7..1256cdd 100644 --- a/todo.md +++ b/todo.md @@ -1,12 +1,10 @@ # todo -- property binding optimization - - would no longer allow `cleanup()` usage in binding scopes -- solution to nested reactivity, see: SolidJS stores - optimize wide graph updating - implement from solid: - - Portal - - batch + - stores + - portals + - batch() - optimize `indexes()` double-diffing -- improve crash course, some sections feel like information dumps - cleanup source and tests +- prevent redundant re-eval of nodes in a complex diamond graph From ec998ccbc8ddd229a94039c76268de7286843580 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Fri, 27 Oct 2023 16:47:49 +0100 Subject: [PATCH 06/94] Implement batched updates No docs yet, more testing needed. --- src/batch.luau | 23 +++++++++++++++++++++++ src/flags.luau | 2 +- src/graph.luau | 23 +++++++++++++++++++++++ src/init.luau | 4 +++- test/benchmark.luau | 38 ++++++++++++++++++++++++++++++-------- test/tests.luau | 43 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 src/batch.luau diff --git a/src/batch.luau b/src/batch.luau new file mode 100644 index 0000000..3c08127 --- /dev/null +++ b/src/batch.luau @@ -0,0 +1,23 @@ +if not game then script = require "test/relative-string" end + +local flags = require(script.Parent.flags) +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) + +local function batch(setter: () -> ()) + local already_batching = flags.batch + + flags.batch = true + + local ok, err: string? = pcall(setter) + + flags.batch = false + + if not ok then throw(`error occured while batching updates: {err}`) end + + if not already_batching then -- todo: flush anyways? + graph.flush_update_queue() + end +end + +return batch diff --git a/src/flags.luau b/src/flags.luau index 1b9f80e..cc2d2f8 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -4,4 +4,4 @@ end local is_O2 = inline_test() ~= "inline_test" -return { strict = not is_O2 } +return { strict = not is_O2, batch = false } diff --git a/src/graph.luau b/src/graph.luau index d316e78..e32f3d0 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -193,10 +193,32 @@ local function queue_children(node: StartNode) update_queue.n = i end +local function flush_update_queue() + -- todo: test with recursive batch sets + local n0 = 0 + + local i = n0 + 1 + while i <= update_queue.n do + local node = update_queue[i] + --assert(node.effect) + + if evaluate_node(node) then + queue_children(node) + end + + update_queue[i] = false :: any + i += 1 + end + + update_queue.n = n0 +end + local function update(root: StartNode) local n0 = update_queue.n queue_children(root) + if flags.batch then return end + local i = n0 + 1 while i <= update_queue.n do local node = update_queue[i] @@ -257,5 +279,6 @@ return table.freeze { create_node = create_node, create_start_node = create_start_node, get_children = get_children, + flush_update_queue = flush_update_queue, scopes = scopes } diff --git a/src/init.luau b/src/init.luau index 687f417..cfc49d0 100644 --- a/src/init.luau +++ b/src/init.luau @@ -11,9 +11,10 @@ local create = require(script.create) local apply = require(script.apply) local source = require(script.source) local effect = require(script.effect) +local derive = require(script.derive) local cleanup = require(script.cleanup) local untrack = require(script.untrack) -local derive = require(script.derive) +local batch = require(script.batch) local switch = require(script.switch) local show = require(script.show) local indexes, values = require(script.maps)() @@ -59,6 +60,7 @@ local vide = { -- util cleanup = cleanup, untrack = untrack, + batch = batch, read = function(value: T | () -> T): T return if type(value) == "function" then value() else value end, diff --git a/test/benchmark.luau b/test/benchmark.luau index aac9071..bee8c68 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -6,6 +6,7 @@ local source = vide.source local derive = vide.derive local indexes = vide.indexes local values = vide.values +local batch = vide.batch local cleanup = vide.cleanup local create = vide.create @@ -132,30 +133,51 @@ ROOT_BENCH("update 1->1->1->1...1000 graph", function() end end) --- todo: crashes at 1k --- todo: repeat with batching -ROOT_BENCH("update 1000->1 graph", function() +-- todo: why does it hang at 1k? it didn't before +ROOT_BENCH("update 500->1 graph", function() local srcs = {} - for i = 1, 800 do + for i = 1, 500 do srcs[i] = source(0) end derive(function() - for i = 1, 800 do + for i = 1, 500 do srcs[i]() end return false end) for i = 1, START(1) do - for idx = 1, 800 do + for idx = 1, 500 do srcs[idx](i) end end end) --- todo: optimize, repeat with batching -ROOT_BENCH("update 1000x 1->1 common extern. graph", function() +ROOT_BENCH("update 1000->1 graph (batched)", function() + local srcs = {} + for i = 1, 1000 do + srcs[i] = source(0) + end + + derive(function() + for i = 1, 1000 do + srcs[i]() + end + return false + end) + + for i = 1, START(1) do + batch(function() + for idx = 1, 1000 do + srcs[idx](i) + end + end) + end +end) + +-- todo: optimize this case +ROOT_BENCH("update 1000 1->1 common extern. graph", function() local ext = source(-1) local srcs = {} diff --git a/test/tests.luau b/test/tests.luau index 5c51e5f..16fa50d 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1800,6 +1800,49 @@ TEST("changed()", wrap_root(function() end end)) +TEST("batch()", wrap_root(function() + local source = vide.source + local derive = vide.derive + local batch = vide.batch + + do CASE "child evaluation halted" + local a = source(0) + + local count = { b = 0, b2 = 0, c = 0 } + + local b = derive(function() + count.b += 1 + return a() + 1 + end) + + local b2 = derive(function() + count.b2 += 1 + return a() + 2 + end) + + local c = derive(function() + count.c += 1 + return b() + b2() + end) + + batch(function() + a(1) + CHECK(count.b == 1) + CHECK(count.b2 == 1) + CHECK(count.c == 1) + end) + + CHECK(count.b == 2) + CHECK(count.b2 == 2) + CHECK(count.c == 2) + + CHECK(b() == 2) + CHECK(c() == 5) + end + + -- todo: test batch call in recursive set +end)) + TEST("read()", wrap_root(function() local source = vide.source local effect = vide.effect From 65fe3fcf47547cbfe4960d276b59601e1f57245e Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 16 Nov 2023 18:01:45 +0000 Subject: [PATCH 07/94] Fix some graph edge cases --- CHANGELOG.md | 14 ++++++++- src/graph.luau | 25 ++++++--------- test/tests.luau | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558b398..5ea3ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -- +### Added + +- Batched updates with `batch()`. + +### Changed + +- Improved graph updating algorithm. +- Graph nodes no longer destroy children; only owned. + +### Fixed + +- Graph edge case where a destroyed node can be readded if it was queued for + evaluation before being destroyed. -------------------------------------------------------------------------------- diff --git a/src/graph.luau b/src/graph.luau index e32f3d0..9288a82 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -99,27 +99,16 @@ end local function find_and_swap_pop(t: { T }, v: T) local idx = table.find(t, v) :: number - --assert(idx, "value not found") local n = #t t[idx] = t[n] t[n] = nil end -local function remove_child(parent: StartNode, child: Node) - find_and_swap_pop(parent, child) -end - -local function disown(node: Node) - if node.owner then - find_and_swap_pop(node.owner.owned :: { Node }, node) - end -end - local function unparent(node: Node) local parents = node.parents for i, parent in next, parents do - remove_child(parent, node) + find_and_swap_pop(parent, node) parents[i] = nil end end @@ -127,13 +116,16 @@ end local function destroy(node: Node) run_cleanups(node) unparent(node) - disown(node) + + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) + node.owner = false + end if node.owned then local owned = node.owned while owned[1] do destroy(owned[1]) end end - while node[1] do destroy(node[1]) end end local function destroy_owned(node: Node) @@ -202,7 +194,7 @@ local function flush_update_queue() local node = update_queue[i] --assert(node.effect) - if evaluate_node(node) then + if node.owner and evaluate_node(node) then queue_children(node) end @@ -224,7 +216,8 @@ local function update(root: StartNode) local node = update_queue[i] --assert(node.effect) - if evaluate_node(node) then + -- check if node is still owned in case destroyed after queued + if node.owner and evaluate_node(node) then queue_children(node) end diff --git a/test/tests.luau b/test/tests.luau index 16fa50d..9cd6afe 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1923,6 +1923,7 @@ TEST("graph edge cases", wrap_root(function() local source = vide.source local derive = vide.derive local effect = vide.effect + local root = vide.root do CASE "diamond A,B,C,D" --[[ @@ -2006,6 +2007,89 @@ TEST("graph edge cases", wrap_root(function() CHECK(b() == 2) CHECK(count == 2) end + + do CASE "do not destroy children" + local parent = source(0) + + local + destroy, + parent_to_destroy, + update_parent_to_destroy + = root(function(destroy) + local src = source(0) + return + destroy, + derive(function() return src() end), + src + end) + + local count = 0 + + effect(function() + count += 1 + parent() + parent_to_destroy() + end) + + parent(parent() + 1) + CHECK(count == 2) + update_parent_to_destroy(1) + CHECK(count == 3) + + destroy() + + update_parent_to_destroy(2) + CHECK(count == 3) + + parent(parent() + 1) + CHECK(count == 4) + end + + do CASE "double destroy" + -- issue: + -- parent evaluates + -- child A queued + -- child B queued + -- child A destroys child B + -- child B reevaluates due to already being queued + -- parent destroys, destroys child B - uh oh + + local + destroy_parent, + parent, + update_parent + = root(function(destroy) + local src = source(0) + return + destroy, + derive(function() return src() end), + src + end) + + local destroy_child, _child_B = function() end, nil + + local count_A = 0 + + -- child_A + effect(function() + count_A += 1 + parent() + destroy_child() + end) + + local count_B = 0 + destroy_child, _child_B = root(function(destroy) + return + destroy, + derive(function() count_B += 1; return parent() end) + end) + + update_parent(parent() + 1) + CHECK(count_A == 2) + CHECK(count_B == 1) -- child B should not run again + destroy_parent() -- should not error + CHECK(true) + end end)) TEST("strict", wrap_root(function() From 8eb5f96c5b47406798a2f13b62aee84eb98821dd Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:27:56 +0000 Subject: [PATCH 08/94] Fix recursive `batch()` use --- docs/api/reactivity-utility.md | 18 ++++++++++++++ src/batch.luau | 14 ++++++----- src/graph.luau | 7 +++++- test/tests.luau | 44 ++++++++++++++++++++++++++++++++-- todo.md | 8 +------ 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index a16e251..ee82067 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -73,4 +73,22 @@ read can still be tracked inside a reactive scope. function read(value: T | () -> T): T ``` +## batch() + +Runs a given function where any source updates made within the function do not +trigger effects until after the function runs. + +- **Type** + + ```lua + function batch(fn: () -> ()) + ``` + +- **Details** + + Improves performance when an effect depends on multiple sources, and those + sources need to be updated. Updating those sources inside a batch call will + only cause the effect to run once after the batch call ends instead of after + each time a source is updated. + -------------------------------------------------------------------------------- diff --git a/src/batch.luau b/src/batch.luau index 3c08127..1951789 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -11,13 +11,15 @@ local function batch(setter: () -> ()) local ok, err: string? = pcall(setter) - flags.batch = false - - if not ok then throw(`error occured while batching updates: {err}`) end - - if not already_batching then -- todo: flush anyways? - graph.flush_update_queue() + if not already_batching then + flags.batch = false + + if not already_batching then + graph.flush_update_queue() + end end + + if not ok then throw(`error occured while batching updates: {err}`) end end return batch diff --git a/src/graph.luau b/src/graph.luau index 9288a82..66587a8 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -185,8 +185,11 @@ local function queue_children(node: StartNode) update_queue.n = i end +local _flushing = false local function flush_update_queue() - -- todo: test with recursive batch sets + assert(not flushing, "recursive queue flush occured") -- todo + _flushing = true + local n0 = 0 local i = n0 + 1 @@ -203,6 +206,8 @@ local function flush_update_queue() end update_queue.n = n0 + + _flushing = false end local function update(root: StartNode) diff --git a/test/tests.luau b/test/tests.luau index 9cd6afe..94c7bb4 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1805,7 +1805,7 @@ TEST("batch()", wrap_root(function() local derive = vide.derive local batch = vide.batch - do CASE "child evaluation halted" + do CASE "evaluation deferred" local a = source(0) local count = { b = 0, b2 = 0, c = 0 } @@ -1840,7 +1840,47 @@ TEST("batch()", wrap_root(function() CHECK(c() == 5) end - -- todo: test batch call in recursive set + do CASE "recursive call" + local a1 = source(0) + local a2 = source(0) + local a3 = source(0) + + local count = { b1 = 0, b2 = 0, b3 = 0 } + + local b1 = derive(function() + count.b1 += 1 + return a1() + 1 + end) + + local b2 = derive(function() + count.b2 += 1 + return a2() + 1 + end) + + local b3 = derive(function() + count.b3 += 1 + return a3() + 1 + end) + + batch(function() + a1(1) + batch(function() + a2(2) + end) + a3(3) + CHECK(count.b1 == 1) + CHECK(count.b2 == 1) + CHECK(count.b3 == 1) + end) + + CHECK(count.b1 == 2) + CHECK(count.b2 == 2) + CHECK(count.b3 == 2) + + CHECK(b1() == 2) + CHECK(b2() == 3) + CHECK(b3() == 4) + end end)) TEST("read()", wrap_root(function() diff --git a/todo.md b/todo.md index 1256cdd..6f254bd 100644 --- a/todo.md +++ b/todo.md @@ -1,10 +1,4 @@ # todo -- optimize wide graph updating -- implement from solid: - - stores - - portals - - batch() -- optimize `indexes()` double-diffing -- cleanup source and tests +- improve error traces - prevent redundant re-eval of nodes in a complex diamond graph From c288cb92c4881608e38585ddfc472a8959c15bea Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:53:30 +0000 Subject: [PATCH 09/94] Minor refactors Also fixed potential bug with `create()` being called recursively if a property binding passed as a property to `create()` also calls `create()` --- src/action.luau | 6 +- src/apply.luau | 148 ++++++++++++++++++++++++++--------------------- src/bind.luau | 5 +- src/create.luau | 50 +++++++++------- src/derive.luau | 4 +- src/effect.luau | 4 +- src/graph.luau | 6 +- src/init.luau | 5 +- src/maps.luau | 6 +- src/memoize.luau | 17 ------ src/read.luau | 7 +++ src/source.luau | 2 +- src/spring.luau | 4 +- src/switch.luau | 6 +- src/throw.luau | 2 +- test/tests.luau | 27 +++++++++ 16 files changed, 169 insertions(+), 130 deletions(-) delete mode 100644 src/memoize.luau create mode 100644 src/read.luau diff --git a/src/action.luau b/src/action.luau index f40bf3a..8cc4987 100644 --- a/src/action.luau +++ b/src/action.luau @@ -10,14 +10,14 @@ local function is_action(v: any) end local function action(callback: (Instance) -> (), priority: number?): Action - local t = { + local a = { priority = priority or 1, callback = callback } - setmetatable(t :: any, ActionMT) + setmetatable(a :: any, ActionMT) - return table.freeze(t) + return table.freeze(a) end return function() diff --git a/src/apply.luau b/src/apply.luau index 1cc533a..13e8cab 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -13,41 +13,58 @@ type Node = graph.Node type Array = { V } type Map = { [K]: V } --- buffer of event -> callback to connect after properties are set -local event_buffer = {} :: Map ()> +local free_caches: { + -- event listeners to connect after properties are set + events: Map< + string, -- event name + () -> () -- listener + >, --- buffer of priority -> callback to run after events are connected -local action_buffers = {} :: Map ()>> + -- actions to run after events are connected + actions: Map< + number, -- priority + Array<(Instance) -> ()> -- action callbacks + >, --- lazily create buffers on nil index -setmetatable(action_buffers :: any, { - __index = function(_, i: number) - action_buffers[i] = {} - return action_buffers[i] + -- cache to detect duplicate property setting at same nesting depth + nested_debug: Map< + number, -- depth + Map -- set of property names + >, + + -- use stack instead of recursive function to process nesting 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 } + nested_stack: { {} | number } +}? + +local function borrow_caches(): typeof(assert(free_caches)) + if free_caches then + local caches = free_caches :: typeof(assert(free_caches)) + free_caches = nil + return caches + else + return { + events = {}, + actions = setmetatable({} :: any, { -- lazy init + __index = function(self, i) self[i] = {}; return self[i] end + }), + nested_debug = setmetatable({} :: any, { + __index = function(self, i: number) self[i] = {}; return self[i] end + }), + nested_stack = {} + } end -}) +end --- cache in strict mode to detect duplicate property set at same nesting level -local nested_debug_cache = {} :: Map> +local function return_caches(caches: typeof(free_caches) ) + free_caches = caches +end -setmetatable(nested_debug_cache :: any, { - __index = function(_, i: number) - nested_debug_cache[i] = {} - return nested_debug_cache[i] - end -}) - --- 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 init local aggregates = {} - -for i, v in next, { +for name, class in { CFrame = CFrame, Color3 = Color3, UDim = UDim, @@ -55,27 +72,39 @@ for i, v in next, { Vector2 = Vector2, Vector3 = Vector3, Rect = Rect -} do - aggregates[i] = v.new +} :: Map do + aggregates[name] = class.new end --- processes a potentially nested table of values to assign to an instance -local function process_props(instance: Instance, properties: Map) +-- applies table of nested properties to an instance using full vide semantics +local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T + if not properties then + throw("attempt to call a constructor returned by create() with no properties") + end + local strict = flags.strict - table.clear(nested_stack) - if strict then table.clear(nested_debug_cache) end + -- queue parent assignment if any for last + local parent: unknown = properties.Parent + local caches = borrow_caches() + local events = caches.events + local actions = caches.actions + local nested_debug = caches.nested_debug + local nested_stack = caches.nested_stack + + -- process all properties local depth = 1 - repeat for property, value in properties do + if property == "Parent" then continue end + if type(property) == "string" then - if strict then -- check for duplicate prop assignment at nesting layer - if nested_debug_cache[depth][property] then + if strict then -- check for duplicate prop assignment at nesting depth + if nested_debug[depth][property] then throw(`duplicate property {property} at depth {depth}`) end - nested_debug_cache[depth][property] = true + nested_debug[depth][property] = true end if type(value) == "table" then -- attempt aggregate init @@ -86,7 +115,7 @@ local function process_props(instance: Instance, properties: Map () -- add event to buffer + events[property] = value :: () -> () -- add event to buffer else bind.property(instance, property, value :: () -> ()) -- bind property end @@ -98,7 +127,7 @@ local function process_props(instance: Instance, properties: Map 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 + table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer else table.insert(nested_stack, value :: {}) table.insert(nested_stack, depth + 1) -- push table to stack for later processing @@ -109,40 +138,17 @@ local function process_props(instance: Instance, properties: Map(instance: T & Instance, properties: { [unknown]: unknown }): T - if not properties then - throw("no properties given, did you forget to call the constructor returned by create()?") + for event, listener in next, events do + (instance :: any)[event]:Connect(listener) end - -- queue parent assignment if any for last - local parent: unknown = properties.Parent - if parent then properties.Parent = nil end - - -- reset buffers - table.clear(event_buffer) - for _, buffer in next, action_buffers do - table.clear(buffer) - end - - -- process all properties for immediate setting or buffering - process_props(instance, properties) - - -- connect buffered events - for event, fn in next, event_buffer do - (instance :: any)[event]:Connect(fn) - end - - -- run buffered actions - for _, buffer in next, action_buffers do - for _, callback in next, buffer do + for _, queued in next, actions do + for _, callback in next, queued do callback(instance) end end @@ -156,6 +162,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end end + -- clear caches + table.clear(events) + for _, queued in next, actions do table.clear(queued) end + if strict then table.clear(nested_debug) end + table.clear(nested_stack) + + return_caches(caches) + return instance end diff --git a/src/bind.luau b/src/bind.luau index c614590..4a204f1 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -5,7 +5,7 @@ local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner @@ -31,8 +31,7 @@ function create_binding(updater: (T) -> T, binding: T) end end - - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(binding, updater) diff --git a/src/create.luau b/src/create.luau index 0ce889e..7adfc68 100644 --- a/src/create.luau +++ b/src/create.luau @@ -5,41 +5,51 @@ local Instance = game and Instance or require "test/mock".Instance :: never local throw = require(script.Parent.throw) local defaults = require(script.Parent.defaults) local apply = require(script.Parent.apply) -local memoize = require(script.Parent.memoize) + +local ctor_cache = {} :: { [string]: () -> Instance } + +setmetatable(ctor_cache :: any, { + __index = function(self, class) + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then throw(`invalid class name, could not create instance of class { class }`) end + + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in next, default do + (instance :: any)[i] = v + end + end + + local function ctor(properties: Props): Instance + return apply(instance:Clone(), properties) + end + + self[class] = ctor + return ctor + end +}) local function create_instance(class: string) - local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then throw(`invalid class name, could not create instance of class { class }`) end - - local default: { [string]: unknown }? = defaults[class] - if default then - for i, v in next, default do - (instance :: any)[i] = v - end - end - - return function(properties: { [any]: unknown }): Instance - return apply(instance:Clone(), properties) - end -end; create_instance = memoize(create_instance) -- always return same constructor for given class + return ctor_cache[class] +end local function clone_instance(instance: Instance) - return function(properties: { [any]: unknown }): Instance + return function(properties: Props): Instance local clone = instance:Clone() - if not clone then error("Attempt to clone a non-archivable instance", 3) end + if not clone then throw "attempt to clone a non-archivable instance" end return apply(clone, properties) end end -local function create(class_or_instance: string|Instance) +local function create(class_or_instance: string|Instance): (Props) -> Instance if type(class_or_instance) == "string" then return create_instance(class_or_instance) elseif typeof(class_or_instance) == "Instance" then return clone_instance(class_or_instance) else - throw("bad argument #1, expected string or instance, got "..typeof(class_or_instance)) + throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) + return nil :: never end - return nil :: never end type Props = { [any]: any } diff --git a/src/derive.luau b/src/derive.luau index 49094d5..863fd98 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -4,11 +4,11 @@ local graph = require(script.Parent.graph) local create_node = graph.create_node local set_owner = graph.set_owner local track = graph.track -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local function derive(source: () -> T): () -> T - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(false :: any, source) diff --git a/src/effect.luau b/src/effect.luau index 43b12ab..bbe1669 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -2,12 +2,12 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) local create_node = graph.create_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner local function effect(callback: (T) -> T, initial_value: T) - local owner = get_owning_scope() + local owner = assert_owning_scope() local node = create_node(initial_value, callback) diff --git a/src/graph.luau b/src/graph.luau index 66587a8..ecda1ab 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -40,7 +40,7 @@ local function get_scope(): Node? return scopes[scopes.n] end -local function get_owning_scope(): Node +local function assert_owning_scope(): Node local scope = get_scope() if not scope then @@ -187,7 +187,7 @@ end local _flushing = false local function flush_update_queue() - assert(not flushing, "recursive queue flush occured") -- todo + assert(not _flushing, "recursive queue flush occured") -- todo _flushing = true local n0 = 0 @@ -266,7 +266,7 @@ return table.freeze { close_scope = close_scope, evaluate_node = evaluate_node, get_scope = get_scope, - get_owning_scope = get_owning_scope, + assert_owning_scope = assert_owning_scope, add_cleanup = add_cleanup, set_owner = set_owner, destroy = destroy, diff --git a/src/init.luau b/src/init.luau index cfc49d0..17812c3 100644 --- a/src/init.luau +++ b/src/init.luau @@ -14,6 +14,7 @@ local effect = require(script.effect) local derive = require(script.derive) local cleanup = require(script.cleanup) local untrack = require(script.untrack) +local read = require(script.read) local batch = require(script.batch) local switch = require(script.switch) local show = require(script.show) @@ -60,10 +61,8 @@ local vide = { -- util cleanup = cleanup, untrack = untrack, + read = read, batch = batch, - read = function(value: T | () -> T): T - return if type(value) == "function" then value() else value - end, -- animations spring = spring, diff --git a/src/maps.luau b/src/maps.luau index 1034e9a..ef36209 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -10,7 +10,7 @@ local create_start_node = graph.create_start_node local set_owner = graph.set_owner local track = graph.track local update = graph.update -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local open_scope = graph.open_scope local close_scope = graph.close_scope local evaluate_node = graph.evaluate_node @@ -28,7 +28,7 @@ local function check_primitives(t: {}) end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = get_owning_scope() + local owner = assert_owning_scope() local subowner = create_node(false, false) set_owner(subowner, owner) @@ -123,7 +123,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = get_owning_scope() + local owner = assert_owning_scope() local subowner = create_node(false, false) set_owner(subowner, owner) diff --git a/src/memoize.luau b/src/memoize.luau deleted file mode 100644 index cf83427..0000000 --- a/src/memoize.luau +++ /dev/null @@ -1,17 +0,0 @@ -local function memoize(f: (X) -> Y): (X) -> Y - local cache: { [X]: Y? } = {} - - return function(x: X): Y - local y = cache[x] - - if not y then - y = f(x) - cache[x] = y - end - - return y :: Y - end -end - -return memoize - diff --git a/src/read.luau b/src/read.luau new file mode 100644 index 0000000..d3a2fb7 --- /dev/null +++ b/src/read.luau @@ -0,0 +1,7 @@ +if not game then script = require "test/relative-string" end + +local function read(value: T | () -> T): T + return if type(value) == "function" then value() else value +end + +return read diff --git a/src/source.luau b/src/source.luau index f1307fc..dd633bb 100644 --- a/src/source.luau +++ b/src/source.luau @@ -6,7 +6,7 @@ local create_start_node = graph.create_start_node local track = graph.track local update = graph.update -export type Source = (() -> T) & ((T) -> T) +export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source local node = create_start_node(initial_value) diff --git a/src/spring.luau b/src/spring.luau index b627a74..91f17b1 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -27,7 +27,7 @@ type Node = graph.Node type StartNode = graph.StartNode local create_node = graph.create_node local create_start_node = graph.create_start_node -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local evaluate_node = graph.evaluate_node local update = graph.update local set_owner = graph.set_owner @@ -150,7 +150,7 @@ local springs: { [SpringData]: StartNode } = {} setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local owner = get_owning_scope() + local owner = assert_owning_scope() -- https://en.wikipedia.org/wiki/Damping diff --git a/src/switch.luau b/src/switch.luau index 1ddc8e6..12fd376 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -9,14 +9,14 @@ local evaluate_node = graph.evaluate_node local set_owner = graph.set_owner local track = graph.track local destroy = graph.destroy -local get_owning_scope = graph.get_owning_scope +local assert_owning_scope = graph.assert_owning_scope local open_scope = graph.open_scope local close_scope = graph.close_scope type Map = { [K]: V } local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = get_owning_scope() + local owner = assert_owning_scope() return function(map) local last_scope: Node? @@ -35,7 +35,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () if component == nil then return nil end if type(component) ~= "function" then - throw("map must map a value to a function") + throw "map must map a value to a function" end local new_scope = create_node(false, false) diff --git a/src/throw.luau b/src/throw.luau index d3ea687..70b7973 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end local trace = require(script.Parent.trace) local function throw(msg): any - error(msg, trace()-1) + error(msg, trace() - 1) end return throw diff --git a/test/tests.luau b/test/tests.luau index 94c7bb4..c38c6a7 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -942,6 +942,33 @@ TEST("create()", wrap_root(function() CHECK(not wref[1]) end + do CASE "recursive create" + local set_test_to_true = vide.action(function(self) (self :: any).test = true end) + + local f2 + + local to_apply = { + { a = 1 }, + set_test_to_true, + b = function() f2 = create "Frame" { a = 2 } end, + } :: { [number|string]: unknown } + + -- do -- confirm iteration order + -- local t = {} + -- for i in to_apply do + -- table.insert(t, i) + -- end + -- assert(t[1] == "a") + -- end + + local f = create "Frame" (to_apply) + + CHECK((f :: any).a == 1) + CHECK((f :: any).test == true ) + + CHECK((f2 :: any).a == 2) + end + do CASE "garbage collection test" local wref From 338c66ed57162063c87b5a61b73950685ea5338e Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Tue, 21 Nov 2023 18:48:47 +0000 Subject: [PATCH 10/94] Try improve crash course --- docs/.vitepress/config.ts | 3 +- docs/tut/crash-course/1-introduction.md | 46 ++---- docs/tut/crash-course/10-cleanup.md | 26 +++- docs/tut/crash-course/15-concepts.md | 132 ++++++++++++++++++ docs/tut/crash-course/2-creation.md | 39 +++--- docs/tut/crash-course/3-components.md | 14 +- docs/tut/crash-course/4-source.md | 8 +- docs/tut/crash-course/6-root.md | 16 ++- docs/tut/crash-course/7-stateful-component.md | 7 +- docs/tut/crash-course/8-property-binding.md | 14 +- docs/tut/crash-course/9-derived-source.md | 20 +-- 11 files changed, 223 insertions(+), 102 deletions(-) create mode 100644 docs/tut/crash-course/15-concepts.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7c820ff..0a3500d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -51,7 +51,8 @@ export default withMermaid({ { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, { text: "Actions", link: "/tut/crash-course/13-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" } + { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/15-concepts" } ] }, { diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 604f7ae..261ce83 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -1,45 +1,25 @@ # Introduction -This is a brief tutorial designed to give you a quick run through the usage of -Vide. +This is a tutorial that introduces the concepts and usage of Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). -This tutorial assumes familiarity with Luau and Roblox GUI. +This tutorial assumes familiarity with Luau and Roblox UI. ## Why Vide? -Creating UI is a slow and tedious process. The purpose of Vide is to make UI -declarative and concise, making it faster to create and more importantly easier -to maintain. Vide achieves this using a reactive style of programming which -allows you to focus on the flow of data through your application without -worrying about manually updating UI instances. +Creating UI is complicated, slow, and tedious. + +Vide tries to simplify and speed up this process by providing a declarative and +reactive of style programming, which lets you focus more on designing the UI +itself and not having to manually update or reparent UI instances. Some of the main focuses behind Vide's design choices: -- Concise syntax. -- Being completely typecheckable. -- Independence from instance lifetimes. -- Real reactivity. +- Minimal syntax. +- Complete typechecking +- Independence from instances. -## Structure Of A Vide App - -The entry point for all Vide apps is the `mount()` function. This function -sets up Vide's reactivity system. It takes and calls a function that should -create your entire app, and will apply its result to a target. - -In Vide, your app should be composed of functions, each function creates a -specific part of your app, and can be reused if needed. These functions are -called *components*. - -```lua -local function App() - return { - PlayerStats(), - Inventory(), - Settings() - } -end - -mount(App, game.StarterGui) -``` +As with most declarative libraries, there is an initial learning curve to +understand the concepts and usage. This tutorial tries to comprehensively +cover these concepts and usage, more so than you need just to use it. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index 4f62233..b9361ec 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,8 +2,7 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which -is used to register a cleanup callback for the next time the reactive scope -it is called in re-runs. +is used to queue a cleanup callback for the next time a reactive scope re-runs. ```lua local mount = vide.mount @@ -33,18 +32,33 @@ end local unmount = mount(Timer) -unmount() -- all registered cleanups are ran, heartbeat connection stopped +unmount() -- all queued cleanups are ran, heartbeat connection stopped ``` In the above example, this allows us to disconnect the heartbeat connection -when the timer component is destroyed, whether that is from unmounting the app -or if it is dynamically created by a control-flow function, which will be -covered next. +when the reactive scope responsible for creating the timer component is +destroyed, such as when it is unmounted. + +Vide does not see "components", it only sees reactive scopes and how they are +linked together. Components are just a user pattern that creates UI instances +alongside effects. In other words, instances are just a side-effect of the +reactive graph. When a reactive scope is created, you create a corresponding +instance to display that data, when that reactive scope is destroyed, any +cleanups queued will be ran and take care of anything that needs to be, such +as disconnecting connections. This is another reason why `mount()` is used at the top level of your app, so that any registered cleanups created by your app components can be ran when they are destroyed. +Side note: Roblox instances do not need to be explicitly destroyed for their +memory to be freed, they only need to be parented to `nil`. So there is no +need to use `cleanup()` to destroy instances. However, be wary of connecting +a function that references an instance to an event from the same instance, +this causes the instance to reference itself and never be freed. In such a case +you would need to use `cleanup()` to disconnect this connection or to explicitly +destroy the instance. + The reactive graph for the above example: ```mermaid diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md new file mode 100644 index 0000000..2c2c313 --- /dev/null +++ b/docs/tut/crash-course/15-concepts.md @@ -0,0 +1,132 @@ +# Concepts Summary + +A summary of all the concepts covered during the crash course. + +## Source + +A source of data. + +Stores a single value that can be updated by the user. + +## Effect + +Anything that happens in reponse to a source update. + +Vide has built-in functions to create effects such as + +- `effect()` - runs arbitrary user code on source update +- `derive()` - updates a derived source on source update + +## Reactive Scope + +A scope created by certain Vide functions where source updates can be tracked, +and cleanups queued. + +When a source used inside a reactive scope is updated, the reactive scope will +rerun. + +Reactive scopes are created by functions such as + +- `root()` +- `effect()` +- `derive()` + +## Owner + +A reactive scope created within an outer reactive scope, is *owned* by the outer +reactive scope. + +When a reactive scope is re-ran or destroyed, all reactive scopes owned by it +are also destroyed. + +Vide does not let you create reactive scopes without owners. + +## Root Reactive Scope + +A top-level reactive scope. These scopes are an exception to the owner rule. + +Created by `root()`, which `mount()` uses internally. + +A root reactive scope can be created on its own. It allows other reactive scopes +to be created with an owner. + +Root reactive scopes must be destroyed manually by the user, a function to do +this is given by `root()`. + +A root reactive scope can be created within another reactive scope and it will +not automatically be owned by that scope. + +## Cleanup + +Cleans up the result from an effect. + +Unneeded in most cases, a cleanup is arbitrary code that can be ran before +a reactive scope is rerun or destroyed, so that the result from the previous +run can be cleaned up. A cleanup can be queued by using `cleanup()` within +a reactive scope. + +## Tracking + +Reactive scopes are tracking by default, meaning sources read from within scope +will be tracked. + +A reactive scope can be made temporarily non-tracking within `untrack()`, so +that any source used will be ignored. The only function that creates a +nontracking reactive scope by default is `root()`. + +## Reactive Graph + +The combination of reactive scopes can viewed graphically, called a +*reactive graph*. This can be a more intuitive way to think of the +relationships between effects and the sources they depend on. + +### Code + +```lua +local count = source(0) + +root(function() + local text = derive(function() + return "count: " .. text() + end) + + effect(function() + print(text()) + end) +end) +``` + +### Graph resulting from code + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph LR + +subgraph root + text --> effect +end + +count --> text +``` + +Notes: + +- Since `count` is a source, not an effect, it can exist + outside of a root reactive scope. +- An update to `count` will cause `text` to rerun, which + then causes `effect` to rerun. +- When the root reactive scope is destroyed, `text` and + `effect` will be destroyed alongside it, since they are + owned by it. `count` will be untouched and future updates + to `count` will have no effect. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index e66f987..c1cafc0 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -9,36 +9,31 @@ Luau allows us to omit parentheses `()` when calling functions with string or table literals which Vide takes advantage of for brevity. ```lua -local mount = vide.mount local create = vide.create -local function App() - return create "ScreenGui" { - create "Frame" { - AnchorPoint = Vector2.new(0.5, 0.5), - Position = UDim2.fromScale(0.5, 0.5), - Size = UDim2.fromScale(0.4, 0.7), +return create "ScreenGui" { + create "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromScale(0.4, 0.7), - create "TextLabel" { - Text = "hi" - }, + create "TextLabel" { + Text = "hi" + }, - create "TextLabel" { - Text = "bye" - }, + create "TextLabel" { + Text = "bye" + }, - create "TextButton" { - Text = "click me", + create "TextButton" { + Text = "click me", - Activated = function() - print "clicked!" - end - } + Activated = function() + print "clicked!" + end } } -end - -mount(App, game.StarterGui) +} ``` Assign a value to a string key to set a property, and assign a value to a diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index a9f38d9..f6b3ad4 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -1,8 +1,11 @@ # Components +Vide encourages separating different parts of your UI into functions called +*components*. + A component is a function that creates and returns a piece of UI. -This is a way to separate your app into small chunks that you can reuse and put +This is a way to separate your UI into small chunks that you can reuse and put together. ::: code-group @@ -66,12 +69,15 @@ Above is a simple example of a button component being used across files. A single parameter `props` is used to pass properties to the component. -Components allow you to *encapsulate* behavior. You can only modify the -component in ways that you allow in the component, through the `props` parameter. +You can only modify the component in ways that you allow in the component, +through the `props` parameter. To create a new button all you must do is call the `Button` function, passing in values. This saves having to create and set every property each time. Also, when updating the button component in future, any changes to the button file will be seen anywhere the button is used in your app. -This can be extended to much more complicated UI. +The `mount()` function is used to set up Vide's reactivity system when creating +your UI. It only needs to be called once at the top-level with the function that +puts together your entire app. It also parents the returned instance to another +a target instance for you. diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index f5a848a..c356b6b 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -23,9 +23,6 @@ count(count() + 1) -- increment count by 1 Sources can be *derived* by wrapping them in functions. A wrapped source effectively becomes a new source. -Derived sources should be pure functions. This is where the same output is -always produced for the same input no matter how many times it is reran. - ```lua local count = source(0) @@ -40,6 +37,5 @@ print(text()) -- "count: 1" Sources on their own aren't very special, the above can be achieved with plain variables. The real use for sources become apparent when used in combination -with Vide's *reactive scopes*. When a source is read from within a reactive -scope, it can automatically rerun the scope that reads it when the source is -updated in the future. +with *effects*. Similar to a signal and connection, a source and effect allows +you to do things like automatically updating UI when a source is updated. diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md index 2b2d7ad..abeec2f 100644 --- a/docs/tut/crash-course/6-root.md +++ b/docs/tut/crash-course/6-root.md @@ -2,9 +2,9 @@ Any reactive scopes created, such as by `effect()`, must be done so within a "root" reactive scope. This is the main purpose of `mount()`, which you use -once at the top level to create your app as shown in the first introduction. +once at the top level to create your UI. -This is so that when the app is unmounted, it can clean up any reactive scopes +This is so that if you want to destroy your UI, it can stop any reactive scopes created within it, since reactive scopes track any reactive scopes created within them. @@ -53,16 +53,22 @@ The reactive graph for the above example looks like so: graph -subgraph root["mount"] +subgraph root direction LR count --> effect end ``` -When the `mount` scope is destroyed, the `effect` scope will also be destroyed -since it was created within it. +When the root reactive scope created by `mount()` is destroyed, the `effect` +scope will also be destroyed since it was created within it. + +This is important because you may have an effect that updates the property of a +UI instance, meaning the effect is referencing and holding that instance in +memory. The effect being destroyed will remove this reference, allowing the +instance to be garbage collected. You don't need to worry about ensuring all your effects are created within a root scope, since you should be creating all your UI and corresponding effects within a top-level `mount()` call that puts all your UI together. So it is safe to assume that any effect you create will be created under this top level scope. +Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 8de8209..12f16a2 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,6 +1,6 @@ # Stateful Components -A stateful component is a component that stores and displays some data. +A stateful component is a component that can update in reponse to data. Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. @@ -32,9 +32,6 @@ end Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. -Making a property update based on a source is also referred to as *property -binding*. - Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the scope of the component. @@ -70,4 +67,4 @@ count(1) -- the Counter component will update to display this count Sources can be created internally or passed in from externally, there are no restrictions on how they are used as long as the effect using it is created -within a reactive scope so that it can be cleaned up later. +within a reactive scope. diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md index 8304565..a981b8a 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -25,17 +25,17 @@ end This example is equivalent to the example seen on the previous page. -Instead of explicitly creating an effect, assigning a (non-event) property -a function will implicitly create a side-effect to update that property anytime -a dependent source is updated. +Instead of explicitly creating an effect, assigning a (non-event) property a +function will implicitly create an effect to update that property anytime a +source used within is updated. Just like effects, the function is ran immediately in a reactive scope to set -the property initially and determine what sources are being depended on. +the property initially and determine what sources are being used. This allows you as the programmer to not need to manually update UI as the state -of your program changes. You just define how the data sources map to UI, and -Vide's reactive system will automatically update any properties depending on -those sources that were updated. +of your program changes. You just define how data sources map to UI, and Vide's +reactive system will automatically update any properties depending on those +sources. ## Children Binding diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 17e3b77..d3b7156 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -29,13 +29,10 @@ local text = function() return "count: " .. tostring(count()) end -effect(function() - text() -- prints "ran" -end) +effect(function() text() end) +effect(function() text() end) -effect(function() - text() -- prints "ran" again -end) +source(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will @@ -55,16 +52,13 @@ local text = derive(function() return "count: " .. tostring(count()) end) -effect(function() - text() -- prints "ran" -end) +effect(function() text() end) +effect(function() text() end) -effect(function() - text() -- does not print, returns cached value -end) +source(1) -- prints "ran" x1 ``` -`derive()` must also be used within a root reactive scope, just like `effect()`. +`derive()` must also be called within a reactive scope, just like `effect()`. If the recalculated value is the same as the old value, the derived source will not rerun the effects using it. From c527a62ab1dc04be03cabc7e69ae2d401471972c Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:14:00 +0000 Subject: [PATCH 11/94] Bump version to `0.2.0` --- CHANGELOG.md | 7 ++++--- src/init.luau | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea3ffa..83d16e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## Unreleased +## [0.2.0] - 2023-11-22 ### Added @@ -15,12 +15,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Improved graph updating algorithm. -- Graph nodes no longer destroy children; only owned. +- Graph nodes when destroyed no longer destroy children; only owned. ### Fixed - Graph edge case where a destroyed node can be readded if it was queued for - evaluation before being destroyed. + rerun before being destroyed. +- Some properties not being applied when `create()` is used recursively. -------------------------------------------------------------------------------- diff --git a/src/init.luau b/src/init.luau index 17812c3..840f7f9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- vide.luau --- v0.1.1 +-- v0.2.0 -------------------------------------------------------------------------------- if not game then script = require "test/relative-string" end From 50244c2bd8bf21f8bf031970cfa9d7a99dc6c727 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:34:02 +0000 Subject: [PATCH 12/94] Update wally version --- wally.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wally.toml b/wally.toml index d4d48ed..b1387e5 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.1.1" +version = "0.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] From 3aed45212a1bb96ce1d8ba18649fc6482d02f94f Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Fri, 24 Nov 2023 11:09:32 +0000 Subject: [PATCH 13/94] Fix site mermaid rendering Vitepress rc 26 breaks mermaid plugin - use rc 25. --- docs/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/package.json b/docs/package.json index e156bea..5dce993 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { - "vitepress": "^1.0.0-rc.4", - "vitepress-plugin-mermaid": "^2.0.14" + "vitepress": "1.0.0-rc.25", + "vitepress-plugin-mermaid": "2.0.14" } } From d682161c06229f9cb280041fe817680456ad9784 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 7 Dec 2023 17:26:01 +0000 Subject: [PATCH 14/94] Update docs --- docs/.vitepress/config.ts | 2 +- docs/tut/advanced/nested-scoping.md | 188 +++++++++++++ docs/tut/advanced/reactive-scoping.md | 253 ------------------ docs/tut/crash-course/10-cleanup.md | 45 +--- docs/tut/crash-course/11-control-flow.md | 166 ++++-------- docs/tut/crash-course/15-concepts.md | 75 +++--- docs/tut/crash-course/2-creation.md | 5 +- docs/tut/crash-course/3-components.md | 8 +- docs/tut/crash-course/5-effect.md | 8 +- docs/tut/crash-course/6-root.md | 32 ++- docs/tut/crash-course/7-stateful-component.md | 9 +- docs/tut/crash-course/8-property-binding.md | 13 +- src/graph.luau | 4 +- 13 files changed, 315 insertions(+), 493 deletions(-) create mode 100644 docs/tut/advanced/nested-scoping.md delete mode 100644 docs/tut/advanced/reactive-scoping.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0a3500d..87dada1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -58,7 +58,7 @@ export default withMermaid({ { text: "Advanced Reactivity", items: [ - { text: "Reactive Scopes", link: "/tut/advanced/reactive-scoping.md"} + { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} ] } ], diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md new file mode 100644 index 0000000..17c95d2 --- /dev/null +++ b/docs/tut/advanced/nested-scoping.md @@ -0,0 +1,188 @@ +# Nested Reactive Scopes + +Nesting reactive scopes gives you finer control over the reactive graph, but +needs more work to do. The built-in control flow functions try to cover the +most common cases, but they do not cover all of them. + +This tutorial will demonstrate how to implement a `show()` control flow function +using just sources and effects. + +```lua +local mount = vide.mount +local source = vide.source +local show = vide.show + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = count, + Activated = function() count(count() + 1) end + } +end + +mount(function() + local toggled = source(true) + + show(toggled, Button) +end) +``` + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph mount + direction LR + toggle --> show + + subgraph show[show effect] + text[Text effect] + end +end +``` + +Above is the reactive graph for `show()`. It creates a new effect depending on +`toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The +`show` effect calls `Counter`, which creates a new reactive scope to update its +text whenever `count` changes. As per the rules of reactive scopes, a reactive +scope rerunning will destroy any reactive scope created within it. So the text +effect's reactive scope is destroyed whenever the show effect is rerun. + +The same can be achieved without the use of `show()`: + +```lua +local mount = vide.mount +local source = vide.source +local effect = vide.effect +local cleanup = vide.cleanup + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = count, + Activated = function() count(count() + 1) end + } +end + +mount(function() + local toggled = source(true) + + effect(function() + if toggled() then + local destroy = mount(Button) + cleanup(destroy) + end + end) +end) +``` + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph mount + direction LR + toggle --> effect + + subgraph effect + subgraph mount2[inner mount] + text[Text effect] + end + end +end +``` + +This is another way to achieve the same. Here we use `mount()` within the effect +to manually create and destroy a new reactive scope whenever the effect reruns. + +Alternatively, instead of using `mount()`, a new reactive scope can be created +directly within the effect: + +```lua +local mount = vide.mount +local source = vide.source +local effect = vide.effect +local untrack = vide.untrack + +local function Counter() + local count = source(0) + + return create "TextButton" { + Text = count, + Activated = function() count(count() + 1) end + } +end + +mount(function() + local toggled = source(true) + + effect(function() + if toggled() then + untrack(Button) + end + end) +end) +``` + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#1B1B1F", + "primaryTextColor": "#fff", + "primaryBorderColor": "#1B1B1F", + "lineColor": "#79B8FF", + "tertiaryColor": "#161618", + "tertiaryBorderColor": "#1C1C1F" + } +}}%% + +graph + +subgraph mount + direction LR + toggle --> effect + + subgraph effect + text[Text effect] + end +end +``` + +Without the use of `untrack()`, an error would occur, since Vide does not allow +the creation of reactive scopes inside reactive scopes that are tracking. The +reason for this, is because if the `Counter` component reads from a source +internally, that can cause the reactive scope calling `Counter()` to track that +source, causing unintentional reruns. As a guard against this, you are forced to +use `untrack()` to create nested reactive scopes. + +The final result is the same as using the `show()` component. An effect is +created which creates the counter, which creates its own reactive scope. The +effect rerunning causes the counter's internal reactive scope to be destroyed, +making sure everything is cleaned up. + + diff --git a/docs/tut/advanced/reactive-scoping.md b/docs/tut/advanced/reactive-scoping.md deleted file mode 100644 index 20872cf..0000000 --- a/docs/tut/advanced/reactive-scoping.md +++ /dev/null @@ -1,253 +0,0 @@ -# Reactive Scoping - -This is a brief document designed to give the user more insight into how Vide's -reactive system works. - -## Graph Basics - -Vide's reactivity can be represented as a graph, where each source, derived -source, and effect is a node on that graph. The term "*reactive scope*" is just -an abstraction used to refer to these nodes. Each node is a reactive scope. - -Each node stores a cached value, a side-effect function, cleanup functions, -its parents and children, and its owner and owned. - -Whenever a node is updated it will: - -1. destroy its owned nodes -2. run its cleanups -3. rerun its side-effect and update its cached value -4. if its cached value changes, update its children recursively. - -There is a difference between children nodes and owned nodes: - -- children nodes are updated when a parent is updated. -- owned nodes are destroyed when a parent is updated. -- both children and owned are destroyed when a parent is destroyed. - -Nodes created by `root()` generally have no children, and only tracks owned. -Nodes created by `derive()` generally have no owned, and only tracks children. - -## Basic Example - -```lua -root(function() - local forename = source "quan" - local surname = source "xi" - - local name = derive(function() - return forename() .. " " .. surname() - end) - - effect(function() - print("new name: " .. name()) - end) -end) -``` - -This code will produce a graph that looks like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - subgraph root - forename & surname --> name - name --> effect - end -``` - -Nodes connected by arrows represent parent and children connections. -Nodes within other nodes represent owner and owned connections. - -Any time a node is updated, Vide will traverse and update that node's children, -its children's children, etc, until all nodes descending from that node has been -updated. Traversal will stop at a node if that node's cached value does not -change after an update. - -When the side-effect for a node is being reran when a node is updated, any -other nodes read within that side-effect are set as parents of the node -currently being reran. As those nodes are read, we know that the current node -depends on them, so any time those nodes are updated, they will update dependent -nodes since they will be stored as children. - -When destroying a node, its descendents are traversed and also destroyed. -When being destroyed, a node's connections (parents and children, owner and -owned) are cleared, and any pending cleanup functions are ran. - -The purpose of `root()` (which is called internally by `mount()`) is to setup -the root node which will track any node created inside its scope, or any -cleanups registered. Without it, nodes could be garbage collected without a -chance to run pending cleanups which can cause memory leakage. - -Nodes created by `source()` can actually exist outside of root nodes, since -they do not have direct side-effects or cleanups, they do not have to be -explicitly destroyed. - -## Control-flow Graph Example - -Control flow functions in Vide are special, as they can dynamically create and -destroy new root scopes. - -It is the combination of the above which allows us to write components like so: - -```lua -local function Counter(props: { text: string }) - local count = source(0) - - local connection = stepped:Connect(function() count(count() + 1) end) - - cleanup(function() connection:Disconnect() end) - - return create "TextLabel" { - Text = function() - return props.text() .. ": " .. count() - end - } -end -``` - -Vide doesn't recognise this as a "component", that is a user abstraction. Vide -just sees this as a function that creates nodes in the reactive graph. - -```lua -root(function() - local counters = { "A", "B" } - - indexes(counters, function(name) - return Counter { text = name } - end) -end) -``` - -This code produces a graph like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#fff" - } -}}%% - -graph LR - subgraph root - counters --> indexes - - subgraph root1[subroot 1] - n1[name] --> p1[prop binding] - end - - subgraph root2[subroot 2] - n2[name] --> p2[prop binding] - end - end - - indexes .-> root1 & root2 -``` - -This shows how the `indexes()` control flow function creates and manages new -root scopes. The function creates an effect seen as `indexes` in the graph, -which manages the new roots `subroot 1` and `subroot 2`, as well as the sources -`name` for which one exists for each index value in the input table. - -When the input table changes, `indexes()` can automatically destroy and create -subroots based on the changed indexes. Destroyed nodes run any cleanups made, in -this case it is the cleanups to disconnect the counters connection. The same -applies to all other control flow functions. - -Whenever the root reactive scope is destroyed, all its children, `counters` and -`indexes` will be destroyed too, which means that `indexes` children, the -subroots, will also be destroyed. Everything is nicely cleaned up. - -## Custom Control-flow Example - -Below is a simple example of the `show()` control-flow function. - -Each time `visible` changes, `show()` will destroy the current reactive scope -and rerun its function in a new one. - -```lua -local visible = source(true) -local count = source(0) - -root(function() - show(visible, function() - return create "TextLabel" { Text = count } - end) -end) -``` - -The above code produces a graph like so: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1B1B1F" - } -}}%% - -graph LR - subgraph root - direction LR - show - - subgraph subroot["show() subroot"] - p1[prop binding] - end - end - - visible --> show - count --> p1 - show -.- subroot -``` - -This can be recreated without the `show()` control-flow function, with the -following code: - -```lua -local visible = source(true) -local count = source(0) - -root(function() - local output = derive(function() - visible() - - -- untrack so any source read from within this scope - -- will not cause the outer `derive()` call to rerun, - -- we only want `derive()` to rerun when `visible` changes - return untrack(function() - local label = create "TextLabel" {} - - effect(function() - label.Text = count() - end) - - return label - end) - end) -end) -``` - -Both of the above code samples will produce the same visible result. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index b9361ec..bf632f5 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,7 +2,8 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which -is used to queue a cleanup callback for the next time a reactive scope re-runs. +is used to queue a cleanup callback for the next time a reactive scope is rerun +or destroyed. ```lua local mount = vide.mount @@ -32,53 +33,19 @@ end local unmount = mount(Timer) -unmount() -- all queued cleanups are ran, heartbeat connection stopped +unmount() -- all queued cleanups are ran, heartbeat connection disconnected ``` In the above example, this allows us to disconnect the heartbeat connection when the reactive scope responsible for creating the timer component is destroyed, such as when it is unmounted. -Vide does not see "components", it only sees reactive scopes and how they are -linked together. Components are just a user pattern that creates UI instances -alongside effects. In other words, instances are just a side-effect of the -reactive graph. When a reactive scope is created, you create a corresponding -instance to display that data, when that reactive scope is destroyed, any -cleanups queued will be ran and take care of anything that needs to be, such -as disconnecting connections. - -This is another reason why `mount()` is used at the top level of your app, so -that any registered cleanups created by your app components can be ran when -they are destroyed. - -Side note: Roblox instances do not need to be explicitly destroyed for their +::: tip +Roblox instances do not need to be explicitly destroyed for their memory to be freed, they only need to be parented to `nil`. So there is no need to use `cleanup()` to destroy instances. However, be wary of connecting a function that references an instance to an event from the same instance, this causes the instance to reference itself and never be freed. In such a case you would need to use `cleanup()` to disconnect this connection or to explicitly destroy the instance. - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" - } -}}%% - -graph - -subgraph mount - direction LR - cleanup([cleanup]) ~~~ count - count --> bind["effect (text binding)"] -end -``` +::: diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index e5430b8..91f95ac 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -1,123 +1,57 @@ # Control Flow -Eventually you will need a way to dynamically create and destroy UI elements +Eventually you may need a way to dynamically create and destroy UI elements resulting from source updates. Vide provides functions to help you do this, known as *control flow* functions. These functions return new sources, which hold the instances to be displayed. -These sources can be assigned as children, meaning the displayed children -will update when the input source updates. -Control flow functions are special, because they run their components in a new -reactive scope, which can be destroyed independently of the reactive scope that -called the control flow function itself. This means that parts of your app can -be independently created then destroyed. - -## show() - -The most basic control flow function is `show()`, which is used to conditionally -show a component. - -```lua -local source = vide.source -local show = vide.show - -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Activated = function() joined(true) end - } - end - - return create "Frame" { - show(function() return not joined() end, JoinButton) - } -end -``` - -This will make a button to join if you have not joined already. - -You can also pass a third argument, a fallback to show if the condition is falsey. - -```lua -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Activated = function() joined(true) end - } - end - - local function LeaveButton() - return Button { - Activated = function() joined(false) end - } - end - - return create "Frame" { - show(joined, LeaveButton, JoinButton) - } -end -``` - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root["mount() scope"] - direction LR - joined --> show -.- subroot - - subgraph subroot["show() scope"] - direction LR - Button - end -end -``` - -`show()` will implicitly create an effect depending on `joined`, which can be -seen as `show` on the graph. This effect manages, and can create or destroy -a separate reactive scope seen as `show() scope` on the graph. The dotted line -indicates that it isn't actually connected, only indirectly managed through -code. +Control flow functions run their components in a new reactive scope, which can +be destroyed independently of the reactive scope that called the control flow +function. This means parts of your app can be independently created and +destroyed. ## switch() -Similar to `show()`, `switch()`, also condtionally displays one instance at a -time. It is more flexible since it can show one of many components, based on a -table used to map a source value to a component. +`switch()` condtionally displays one instance at a time. It uses a table to map +a source value to a component. ```lua local source = vide.source local switch = vide.switch +local function Button(props: { + Text: string, + Activated: () -> () +}) + local hovered = source(false) + + return create "TextButton" { + Text = props.Text, + Activated = props.Activated, + + TextColor3 = function() + return hovered() and Color3.new(1, 1, 1) or Color3.new(.7, .7, .7) + end, + + MouseEnter = function() hovered(true) end, + MouseLeave = function() hovered(false) end + } +end + local function JoinMenu() local joined = source(false) local function JoinButton() return Button { + Text = "Join", Activated = function() joined(true) end } end local function LeaveButton() return Button { + Text = "Leave" Activated = function() joined(false) end } end @@ -131,22 +65,6 @@ local function JoinMenu() end ``` -This example is equivalent to the previous one. - -The switch can map any value to any component. - -```lua -type ActiveMenu = "none" | "inventory" | "shop" | "settings" - -local menu = source "inventory" - -switch(menu) { - inventory = InventoryMenu, - shop = ShopMenu, - settings = SettingsMenu -} -``` - The reactive graph for the above example: ```mermaid @@ -164,23 +82,30 @@ The reactive graph for the above example: graph -subgraph root["mount() scope"] +subgraph root["root scope"] direction LR - menu --> switch -.- subroot + joined --> switch -.- subroot - subgraph subroot["switch() scope"] + subgraph subroot["switch scope"] direction LR - Menu + effect["TextColor3 effect"] end end ``` +A `switch()` call creates a new effect and a new scope as seen in the above +graph. Whenever `menu` updates, it causes the `switch` effect to run, which +will destroy and recreate the switch scope with the new component. + +This will also destroy the internal effect that the button uses to highlight +itself when it is hovered, each time the switch is rerun. + ## indexes() Often, you will have a table of values with each value displayed in a similar manner. Rather than manually looping over each value to generate a corresponding -UI element, `indexes()` allows you to create elements for each table index, to -display the value at that index. +UI element, `indexes()` allows you to create elements each corresponding to a +table index, to display the value at that index. ```lua local todoList = source { @@ -222,7 +147,8 @@ value), will have its corresponding reactive scope destroyed to clean up that element. `indexes()` is said to *map* each table index to a new UI element that can -update to display the current value at that index. +update to display the current value at that index. Each table index is given a +single corresponding UI element. The reactive graph for the above example: @@ -241,16 +167,16 @@ The reactive graph for the above example: graph -subgraph root ["mount() scope"] +subgraph root ["root scope"] direction LR todoList --> indexes -.- subroot1 & subroot2 - subgraph subroot1 ["indexes() scope 1"] + subgraph subroot1 ["indexes scope 1"] direction LR value1[todo] --> prop1["prop binding"] end - subgraph subroot2 ["indexes() scope 2"] + subgraph subroot2 ["indexes scope 2"] direction LR value2[todo] --> prop2[prop binding] end diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md index 2c2c313..62ee35d 100644 --- a/docs/tut/crash-course/15-concepts.md +++ b/docs/tut/crash-course/15-concepts.md @@ -6,73 +6,64 @@ A summary of all the concepts covered during the crash course. A source of data. -Stores a single value that can be updated by the user. +Stores a single value that can be updated. + +Created with `source()`. + +# Derived Source + +A new source composed of other sources. + +Created with a plain function or with `derive()`. ## Effect -Anything that happens in reponse to a source update. +Anything that happens in response to a source update. -Vide has built-in functions to create effects such as - -- `effect()` - runs arbitrary user code on source update -- `derive()` - updates a derived source on source update +Created with `effect()`. ## Reactive Scope -A scope created by certain Vide functions where source updates can be tracked, -and cleanups queued. - -When a source used inside a reactive scope is updated, the reactive scope will -rerun. - -Reactive scopes are created by functions such as +A scope created by certain functions such as: - `root()` - `effect()` - `derive()` -## Owner +Reactive scopes can: -A reactive scope created within an outer reactive scope, is *owned* by the outer -reactive scope. +- track sources that are read from within. +- rerun when a tracked source updates. +- track new reactive scopes created from within. -When a reactive scope is re-ran or destroyed, all reactive scopes owned by it -are also destroyed. +## Scope Owners -Vide does not let you create reactive scopes without owners. +A reactive scope created within another reactive scope is *owned* by the other +reactive scope, with the exception of the reactive scope created by `root()`. -## Root Reactive Scope +When a reactive scope is rerun or destroyed, all reactive scopes owned by it are +automatically destroyed. -A top-level reactive scope. These scopes are an exception to the owner rule. - -Created by `root()`, which `mount()` uses internally. - -A root reactive scope can be created on its own. It allows other reactive scopes -to be created with an owner. - -Root reactive scopes must be destroyed manually by the user, a function to do -this is given by `root()`. - -A root reactive scope can be created within another reactive scope and it will -not automatically be owned by that scope. +`root()`, which `mount()` uses internally, creates a reactive scope with no +owner, since it must be destroyed manually using a destructor +returned. ## Cleanup -Cleans up the result from an effect. +Arbitrary code to run whenever a reactive scope is rerun or destroyed. -Unneeded in most cases, a cleanup is arbitrary code that can be ran before -a reactive scope is rerun or destroyed, so that the result from the previous -run can be cleaned up. A cleanup can be queued by using `cleanup()` within -a reactive scope. +Queue a function to run using `cleanup()`. ## Tracking -Reactive scopes are tracking by default, meaning sources read from within scope -will be tracked. +Sources read from within a reactive scope will be tracked. This can be disabled +using `untrack()`, which will make reactive scopes temporarily ignore sources +read. -A reactive scope can be made temporarily non-tracking within `untrack()`, so -that any source used will be ignored. The only function that creates a -nontracking reactive scope by default is `root()`. +The reactive scope created by `root()` is non-tracking by default. + +As a guard against misusage, a reactive scope cannot be created within a +reactive scope, unless it is made non-tracking using `untrack()`. ## Reactive Graph diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index c1cafc0..96f9dd2 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -6,7 +6,7 @@ Instances are created using `create()`. properties to assign when creating a new instance for that class. Luau allows us to omit parentheses `()` when calling functions with string or -table literals which Vide takes advantage of for brevity. +table literals which is recommended to use for brevity. ```lua local create = vide.create @@ -44,6 +44,5 @@ to a string key. When creating an instance with no properties, it is important to not forget to actually call the constructor: `create "Frame" {}` and not `create "Frame"`. To be clear, `create "Frame"` returns a *function* which is a constructor for -that class, not an instance of that class. This would result in you attempting -to parent a function instead of an instance which is not correct. +that class, not an instance of that class. ::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index f6b3ad4..aa81916 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -35,7 +35,6 @@ return Button ``` ```lua [App.luau] -local mount = vide.mount local create = vide.create local Button = require(Button) @@ -60,7 +59,7 @@ local function App() } end -mount(App, game.StarterGui) +App().Parent = game.StarterGui ``` ::: @@ -76,8 +75,3 @@ To create a new button all you must do is call the `Button` function, passing in values. This saves having to create and set every property each time. Also, when updating the button component in future, any changes to the button file will be seen anywhere the button is used in your app. - -The `mount()` function is used to set up Vide's reactivity system when creating -your UI. It only needs to be called once at the top-level with the function that -puts together your entire app. It also parents the returned instance to another -a target instance for you. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 9f0dd6b..275a2e6 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -21,11 +21,11 @@ count(1) -- "count: 1" printed ``` -The callback given to `effect()` is initially ran immediately in a -*reactive scope*. Any source read from inside a reactive scope will be tracked, -so that if any of those sources update, the effect will be reran too. +The callback given to `effect()` is ran immediately in a *reactive scope*. Any +source read from inside a reactive scope will be tracked, so when any of those +sources update, the effect will be reran too. -Effects also work with derived sources, it doesn't matter how deeply nested +Reactive scopes also track derived sources, it doesn't matter how deeply nested inside a function a source is. ```lua diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-root.md index abeec2f..4cb47da 100644 --- a/docs/tut/crash-course/6-root.md +++ b/docs/tut/crash-course/6-root.md @@ -1,12 +1,16 @@ # Root Reactive Scopes -Any reactive scopes created, such as by `effect()`, must be done so within a -"root" reactive scope. This is the main purpose of `mount()`, which you use -once at the top level to create your UI. +Reactive scopes cannot be created on their own - they must be created within +another reactive scope so that it can be tracked and later destroyed when it is +no longer needed. -This is so that if you want to destroy your UI, it can stop any reactive scopes -created within it, since reactive scopes track any reactive scopes created -within them. +This is the purpose of `mount()`, which creates an initial "root", or +"top-level" reactive scope, which all other reactive scopes, such as +ones created by `effect()`, can stem from. + +When this root reactive scope is destroyed, it will ensure all other reactive +scopes created within it are also destroyed, ensuring everything is cleaned up +properly. ```lua local source = vide.source @@ -20,13 +24,15 @@ local function App() end) end -vide.mount(App) -- works! App() -- will error since effect() was not called within a reactive scope + +vide.mount(App) -- works! + ``` -Mounting returns a function that when called will destroy any reactive scopes -created during the `mount()` call. +Mounting returns a function that when called will destroy its reactive scope, +along with any other reactive scopes created inside it. ```lua local unmount = mount(App) @@ -68,7 +74,7 @@ memory. The effect being destroyed will remove this reference, allowing the instance to be garbage collected. You don't need to worry about ensuring all your effects are created within a -root scope, since you should be creating all your UI and corresponding effects -within a top-level `mount()` call that puts all your UI together. So it is safe -to assume that any effect you create will be created under this top level scope. -Vide will prevent you from accidently doing otherwise anyways. +root reactive scope, since you should be creating all your UI and corresponding +effects within a top-level `mount()` call that puts all your UI together. So it +is safe to assume that any effect you create will be created under this top +level scope. Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 12f16a2..85139db 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,6 +1,6 @@ # Stateful Components -A stateful component is a component that can update in reponse to data. +A stateful component is a component that stores some data internally. Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. @@ -27,13 +27,18 @@ local function Counter() return instance end + +mount(Counter, game.StarterGui) ``` Above is an example of a counter component, that when clicked, will increment its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the -count source is created inside the scope of the component. +count source is created inside the component. + +We use `mount()` to create the counter within a reactive scope, which also takes +a second argument to parent the counter to another instance. ## External State diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-property-binding.md index a981b8a..1fe18fc 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-property-binding.md @@ -1,8 +1,7 @@ # Property Binding -Explicitly creating effects to update properties can become verbose when there -are a lot of properties to update. Vide provides a way to *implicitly* create -an effect to update properties on source update. +Explicitly creating effects to update properties can be tedious. Vide provides a +way to *implicitly* create an effect to update properties. ```lua local create = vide.create @@ -12,12 +11,12 @@ local function Counter() local count = source(0) return create "TextButton" { - Text = function() - return "count: " .. count() - end, - Activated = function() count(count() + 1) + end, + + Text = function() + return "count: " .. count() end } end diff --git a/src/graph.luau b/src/graph.luau index ecda1ab..2908e3d 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -45,9 +45,9 @@ local function assert_owning_scope(): Node if not scope then local caller_name = debug.info(2, "n") - return throw(`cannot use {caller_name}() in non-reactive scope, must be used within a root() or mount() callback`) + return throw(`cannot use {caller_name}() in a non-reactive scope`) elseif scope.effect then - throw("cannot create new reactive scope inside of a tracking scope") -- todo: allow this? + throw("cannot create new reactive scope in a tracking reactive scope") end return scope From 3d324cc30bed0b334a1a6d35d580e6c3717cad02 Mon Sep 17 00:00:00 2001 From: ReturnedTrue <58662983+ReturnedTrue@users.noreply.github.com> Date: Mon, 19 Feb 2024 21:06:06 +0000 Subject: [PATCH 15/94] Update strict-mode.md (#24) --- docs/api/strict-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 958672e..a7fca48 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -33,6 +33,6 @@ As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly when binding properties to sources. -It is recommend to develop UI with strict mode and to disable it when pushing to +It is recommended to develop UI with strict mode and to disable it when pushing to production. In Roblox, production code compiles at O2 by default, so you don't need to worry about disabling strict mode unless you have manually enabled it. From e1d69afa20754ab184a8f83286228b3ef2bf6261 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sun, 21 Apr 2024 05:57:29 -0700 Subject: [PATCH 16/94] Fix springs passing negative Color3 values (#25) * Fix springs passing negative Color3 values * Clamp color values between 0 and 1 --- src/spring.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spring.luau b/src/spring.luau index 91f17b1..cc92d9a 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -111,7 +111,7 @@ local vec6_to_type = { end :: Vec6ToType, Color3 = function(v) - return Color3.new(v.X, v.Y, v.Z) + return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1)) end :: Vec6ToType, UDim = function(v) From 67e87110c7f293871e75f236b293f8fc172963e6 Mon Sep 17 00:00:00 2001 From: Someon1e <142684596+Someon1e@users.noreply.github.com> Date: Sun, 23 Jun 2024 18:40:24 +0100 Subject: [PATCH 17/94] Delete .gitattributes (#26) --- .gitattributes | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 876fbd3..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.luau linguist-language=Lua From f2de9b0e63d7581a3924e5254ad37e7da4625f17 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 20 Jun 2024 17:36:25 +0100 Subject: [PATCH 18/94] Refactor --- docs/.vitepress/config.ts | 4 +- docs/api/creation.md | 2 +- docs/api/reactivity-core.md | 9 +- docs/tut/advanced/nested-scoping.md | 31 ++-- docs/tut/crash-course/10-cleanup.md | 14 +- docs/tut/crash-course/11-control-flow.md | 10 +- docs/tut/crash-course/14-strict-mode.md | 2 +- docs/tut/crash-course/15-concepts.md | 56 +++---- docs/tut/crash-course/2-creation.md | 7 - docs/tut/crash-course/3-components.md | 12 -- .../crash-course/{6-root.md => 6-scope.md} | 60 ++++--- docs/tut/crash-course/7-stateful-component.md | 7 +- ...operty-binding.md => 8-implicit-effect.md} | 9 +- docs/tut/crash-course/9-derived-source.md | 4 +- src/bind.luau | 10 +- src/cleanup.luau | 8 +- src/derive.luau | 12 +- src/effect.luau | 8 +- src/graph.luau | 104 ++++++------ src/maps.luau | 83 +++++----- src/root.luau | 10 +- src/source.luau | 12 +- src/spring.luau | 24 ++- src/switch.luau | 25 ++- test/tests.luau | 156 ++++++++---------- 25 files changed, 315 insertions(+), 364 deletions(-) rename docs/tut/crash-course/{6-root.md => 6-scope.md} (51%) rename docs/tut/crash-course/{8-property-binding.md => 8-implicit-effect.md} (85%) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 87dada1..51b477a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -43,9 +43,9 @@ export default withMermaid({ { text: "Components", link: "/tut/crash-course/3-components" }, { text: "Sources", link: "/tut/crash-course/4-source" }, { text: "Effects", link: "/tut/crash-course/5-effect" }, - { text: "Root Scopes", link: "/tut/crash-course/6-root" }, + { text: "Scopes", link: "/tut/crash-course/6-scope" }, { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, - { text: "Property Binding", link: "/tut/crash-course/8-property-binding" }, + { text: "Property Binding", link: "/tut/crash-course/8-implicit-effect" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, diff --git a/docs/api/creation.md b/docs/api/creation.md index 8e67478..46d54c3 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -4,7 +4,7 @@ ## mount() -Runs a function in a new reactive scope and optionally applies its result to a +Runs a function in a new stable scope and optionally applies its result to a target instance. - **Type** diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 133bc4a..652c80a 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -3,12 +3,13 @@
:::warning -Yielding is not allowed in any reactive scope. Strict mode can check for this. +Yielding is not allowed in any stable or reactive scope. Strict mode will check +for this. ::: ## root() -Creates and runs a function in a new reactive scope. +Creates and runs a function in a new stable scope. - **Type** @@ -20,8 +21,8 @@ Creates and runs a function in a new reactive scope. Returns the result of the given function. - Creates a new root reactive scope, where creation and derivations of sources - can be tracked and properly disposed of. + Creates a new stable scope, where creation of effects can be tracked and + properly disposed of. A function to destroy the root is passed into the callback, which will run any cleanups and allow derived sources created to garbage collect. diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md index 17c95d2..fb31c15 100644 --- a/docs/tut/advanced/nested-scoping.md +++ b/docs/tut/advanced/nested-scoping.md @@ -1,7 +1,6 @@ -# Nested Reactive Scopes +# Nested Scopes -Nesting reactive scopes gives you finer control over the reactive graph, but -needs more work to do. The built-in control flow functions try to cover the +Nesting scopes gives you finer control over the reactive graph, but needs more work to do. The built-in control flow functions try to cover the most common cases, but they do not cover all of them. This tutorial will demonstrate how to implement a `show()` control flow function @@ -21,7 +20,7 @@ local function Counter() } end -mount(function() +root(function() local toggled = source(true) show(toggled, Button) @@ -57,7 +56,7 @@ Above is the reactive graph for `show()`. It creates a new effect depending on `toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The `show` effect calls `Counter`, which creates a new reactive scope to update its text whenever `count` changes. As per the rules of reactive scopes, a reactive -scope rerunning will destroy any reactive scope created within it. So the text +scope rerunning will destroy any scopes created within it. So the text effect's reactive scope is destroyed whenever the show effect is rerun. The same can be achieved without the use of `show()`: @@ -82,7 +81,10 @@ mount(function() effect(function() if toggled() then - local destroy = mount(Button) + local destroy = root(function(destroy) + Counter() + return destroy + end) cleanup(destroy) end end) @@ -116,11 +118,14 @@ subgraph mount end ``` -This is another way to achieve the same. Here we use `mount()` within the effect -to manually create and destroy a new reactive scope whenever the effect reruns. +This is another way to achieve the same. Here we use `root()` within the effect +to manually create and destroy a new stable scope whenever the effect reruns. -Alternatively, instead of using `mount()`, a new reactive scope can be created -directly within the effect: +The reason for creating a stable scope is to prevent the effect from tracking +any sources that may be read inside the `Counter()` call. Otherwise, the effect +may be rerun needlessly and recreate the counter. + +Alternatively, instead of using `root()`: ```lua local mount = vide.mount @@ -174,7 +179,9 @@ end ``` Without the use of `untrack()`, an error would occur, since Vide does not allow -the creation of reactive scopes inside reactive scopes that are tracking. The +the creation of reactive scopes inside reactive scopes. `untrack()` creates a +stable scope inside the reactive scope, and we can create another reactive scope +inside that stable scope. The reason for this, is because if the `Counter` component reads from a source internally, that can cause the reactive scope calling `Counter()` to track that source, causing unintentional reruns. As a guard against this, you are forced to @@ -184,5 +191,3 @@ The final result is the same as using the `show()` component. An effect is created which creates the counter, which creates its own reactive scope. The effect rerunning causes the counter's internal reactive scope to be destroyed, making sure everything is cleaned up. - - diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index bf632f5..474f1f2 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -3,7 +3,7 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which is used to queue a cleanup callback for the next time a reactive scope is rerun -or destroyed. +or destroyed, or when a stable scope is destroyed. ```lua local mount = vide.mount @@ -31,14 +31,18 @@ local function Timer() } end -local unmount = mount(Timer) +local instance, destroy = root(function(destroy) + local instance = Timer() + return instance, destroy +end) -unmount() -- all queued cleanups are ran, heartbeat connection disconnected +wait(5) + +destroy() -- all queued cleanups are ran, heartbeat connection disconnected ``` In the above example, this allows us to disconnect the heartbeat connection -when the reactive scope responsible for creating the timer component is -destroyed, such as when it is unmounted. +when the scope responsible for creating the timer component is destroyed. ::: tip Roblox instances do not need to be explicitly destroyed for their diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index 91f95ac..246d65b 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -6,8 +6,8 @@ known as *control flow* functions. These functions return new sources, which hold the instances to be displayed. -Control flow functions run their components in a new reactive scope, which can -be destroyed independently of the reactive scope that called the control flow +Control flow functions run their components in a new stable scope, which can +be destroyed independently of the stable scope that called the control flow function. This means parts of your app can be independently created and destroyed. @@ -93,9 +93,9 @@ subgraph root["root scope"] end ``` -A `switch()` call creates a new effect and a new scope as seen in the above -graph. Whenever `menu` updates, it causes the `switch` effect to run, which -will destroy and recreate the switch scope with the new component. +A `switch()` call creates a new effect and a new stable scope as seen in the +above graph. Whenever `menu` updates, it causes the `switch` effect to run, +which will destroy and recreate the switch scope with the new component. This will also destroy the internal effect that the button uses to highlight itself when it is hovered, each time the switch is rerun. diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/14-strict-mode.md index ca8465a..6efa1c3 100644 --- a/docs/tut/crash-course/14-strict-mode.md +++ b/docs/tut/crash-course/14-strict-mode.md @@ -11,7 +11,7 @@ want this. Strict mode will run derived sources and effects twice each time they update. This is to help ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done correctly. +cleanups made in derived sources or effects are done properly. ```lua local source = vide.source diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/15-concepts.md index 62ee35d..0b9dc0c 100644 --- a/docs/tut/crash-course/15-concepts.md +++ b/docs/tut/crash-course/15-concepts.md @@ -22,52 +22,52 @@ Anything that happens in response to a source update. Created with `effect()`. -## Reactive Scope +## Stable Scope -A scope created by certain functions such as: +One of the two types of Vide scopes. + +Created by: - `root()` +- `untrack()` +- `switch()` +- `indexes()` + +Stable scopes do not track sources and never rerun. + +New stable or reactive scopes can be created within a stable scope. + +## Reactive Scope + +Created by: + - `effect()` - `derive()` -Reactive scopes can: +Reactive scopes do track sources and will rerun when those sources update. -- track sources that are read from within. -- rerun when a tracked source updates. -- track new reactive scopes created from within. +New reactive scopes cannot be created within a reactive scope, but stable scopes +can. ## Scope Owners -A reactive scope created within another reactive scope is *owned* by the other -reactive scope, with the exception of the reactive scope created by `root()`. +A scope created within another scope is *owned* by the other scope, with the +exception of the scope created by `root()`. -When a reactive scope is rerun or destroyed, all reactive scopes owned by it are -automatically destroyed. +When a scope is rerun or destroyed, all scopes owned by it are automatically +destroyed. -`root()`, which `mount()` uses internally, creates a reactive scope with no -owner, since it must be destroyed manually using a destructor -returned. +`root()` creates a stable scope with no owner, instead it is destroyed manually. ## Cleanup -Arbitrary code to run whenever a reactive scope is rerun or destroyed. +Arbitrary code to run whenever a stable or reactive scope is rerun or destroyed. Queue a function to run using `cleanup()`. -## Tracking - -Sources read from within a reactive scope will be tracked. This can be disabled -using `untrack()`, which will make reactive scopes temporarily ignore sources -read. - -The reactive scope created by `root()` is non-tracking by default. - -As a guard against misusage, a reactive scope cannot be created within a -reactive scope, unless it is made non-tracking using `untrack()`. - ## Reactive Graph -The combination of reactive scopes can viewed graphically, called a +The combination of stable and reactive scopes can viewed graphically, called a *reactive graph*. This can be a more intuitive way to think of the relationships between effects and the sources they depend on. @@ -114,10 +114,10 @@ count --> text Notes: - Since `count` is a source, not an effect, it can exist - outside of a root reactive scope. + outside of scopes. - An update to `count` will cause `text` to rerun, which then causes `effect` to rerun. -- When the root reactive scope is destroyed, `text` and +- When the root scope is destroyed, `text` and `effect` will be destroyed alongside it, since they are owned by it. `count` will be untouched and future updates to `count` will have no effect. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 96f9dd2..3cc4fd5 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -39,10 +39,3 @@ return create "ScreenGui" { Assign a value to a string key to set a property, and assign a value to a number key to set a child. Events can be connected to by assigning a function to a string key. - -::: warning -When creating an instance with no properties, it is important to not forget to -actually call the constructor: `create "Frame" {}` and not `create "Frame"`. -To be clear, `create "Frame"` returns a *function* which is a constructor for -that class, not an instance of that class. -::: diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index aa81916..e3cc730 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -58,20 +58,8 @@ local function App() } } end - -App().Parent = game.StarterGui ``` ::: -Above is a simple example of a button component being used across files. - A single parameter `props` is used to pass properties to the component. - -You can only modify the component in ways that you allow in the component, -through the `props` parameter. - -To create a new button all you must do is call the `Button` function, passing in -values. This saves having to create and set every property each time. Also, when -updating the button component in future, any changes to the button file will be -seen anywhere the button is used in your app. diff --git a/docs/tut/crash-course/6-root.md b/docs/tut/crash-course/6-scope.md similarity index 51% rename from docs/tut/crash-course/6-root.md rename to docs/tut/crash-course/6-scope.md index 4cb47da..6148364 100644 --- a/docs/tut/crash-course/6-root.md +++ b/docs/tut/crash-course/6-scope.md @@ -1,43 +1,65 @@ -# Root Reactive Scopes +# Scopes + +Vide operates on the concept of scopes. Vide scopes come in two flavors: +stable and reactive. + +The three main rules for scopes are: + +- Stable scopes never rerun. +- Reactive scopes will rerun on source updates. +- A reactive scope cannot be created within another reactive scope. Reactive scopes cannot be created on their own - they must be created within -another reactive scope so that it can be tracked and later destroyed when it is +a stable scope so that it can be tracked and later destroyed when it is no longer needed. -This is the purpose of `mount()`, which creates an initial "root", or -"top-level" reactive scope, which all other reactive scopes, such as -ones created by `effect()`, can stem from. +This is the purpose of `root()`, which creates an initial stable scope, which +all other reactive scopes, such as ones created by `effect()`, can stem from. -When this root reactive scope is destroyed, it will ensure all other reactive -scopes created within it are also destroyed, ensuring everything is cleaned up -properly. +When this root reactive scope is destroyed, it will destroy any effects created +within it, ensuring everything is cleaned up properly. ```lua local source = vide.source local effect = vide.effect -local function App() +local function setup() local count = source(0) effect(function() print(count()) end) + + return count end +setup() -- will error since effect() was not called within a stable scope -App() -- will error since effect() was not called within a reactive scope - -vide.mount(App) -- works! - +local count = vide.root(setup) -- runs +count(1) -- prints "1" ``` -Mounting returns a function that when called will destroy its reactive scope, -along with any other reactive scopes created inside it. +The scope created by `root()` can be destroyed by calling the function it passes +into the given function. ```lua -local unmount = mount(App) +local function setup(destroy) + local count = source(0) -unmount() + effect(function() + print(count()) + end) + + return count, destroy +end + +local count, destroy = root(setup) + +count(1) -- prints "1" + +destroy() + +count(2) -- effect is destroyed; no longer prints ``` Vide's reactivity can be represented graphically, as a *reactive graph*. @@ -65,7 +87,7 @@ subgraph root end ``` -When the root reactive scope created by `mount()` is destroyed, the `effect` +When the root reactive scope created by `root()` is destroyed, the `effect` scope will also be destroyed since it was created within it. This is important because you may have an effect that updates the property of a @@ -75,6 +97,6 @@ instance to be garbage collected. You don't need to worry about ensuring all your effects are created within a root reactive scope, since you should be creating all your UI and corresponding -effects within a top-level `mount()` call that puts all your UI together. So it +effects within a top-level `root()` call that puts all your UI together. So it is safe to assume that any effect you create will be created under this top level scope. Vide will prevent you from accidently doing otherwise anyways. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 85139db..427d0ee 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -27,8 +27,6 @@ local function Counter() return instance end - -mount(Counter, game.StarterGui) ``` Above is an example of a counter component, that when clicked, will increment @@ -37,9 +35,6 @@ its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the component. -We use `mount()` to create the counter within a reactive scope, which also takes -a second argument to parent the counter to another instance. - ## External State External sources can also be passed into components for them to use. @@ -72,4 +67,4 @@ count(1) -- the Counter component will update to display this count Sources can be created internally or passed in from externally, there are no restrictions on how they are used as long as the effect using it is created -within a reactive scope. +within a stable scope. diff --git a/docs/tut/crash-course/8-property-binding.md b/docs/tut/crash-course/8-implicit-effect.md similarity index 85% rename from docs/tut/crash-course/8-property-binding.md rename to docs/tut/crash-course/8-implicit-effect.md index 1fe18fc..60d7823 100644 --- a/docs/tut/crash-course/8-property-binding.md +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -1,4 +1,4 @@ -# Property Binding +# Implicit Effects Explicitly creating effects to update properties can be tedious. Vide provides a way to *implicitly* create an effect to update properties. @@ -31,12 +31,7 @@ source used within is updated. Just like effects, the function is ran immediately in a reactive scope to set the property initially and determine what sources are being used. -This allows you as the programmer to not need to manually update UI as the state -of your program changes. You just define how data sources map to UI, and Vide's -reactive system will automatically update any properties depending on those -sources. - -## Children Binding +## Children Children can also be set in a similar manner. A source passed as a child (passed with a number key instead of string key) can return an instance or an array of diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index d3b7156..c1e3669 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -36,7 +36,7 @@ source(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will -run a callback in a new reactive scope only when a dependent source has updated. +run a function in a new reactive scope only when a dependent source has updated. Reading this derived source multiple times will just return a cached result from when it last updated. @@ -58,7 +58,7 @@ effect(function() text() end) source(1) -- prints "ran" x1 ``` -`derive()` must also be called within a reactive scope, just like `effect()`. +`derive()` must also be called within a stable scope, just like `effect()`. If the recalculated value is the same as the old value, the derived source will not rerun the effects using it. diff --git a/src/bind.luau b/src/bind.luau index 4a204f1..c6e8ab7 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -5,9 +5,8 @@ local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local assert_owning_scope = graph.assert_owning_scope +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner function create_binding(updater: (T) -> T, binding: T) if flags.strict then @@ -31,12 +30,7 @@ function create_binding(updater: (T) -> T, binding: T) end end - local owner = assert_owning_scope() - - local node = create_node(binding, updater) - - set_owner(node, owner) - evaluate_node(node) + evaluate_node(create_node(assert_stable_scope(), updater, binding)) end type PropertyBinding = { diff --git a/src/cleanup.luau b/src/cleanup.luau index 803be03..8449fcc 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -4,7 +4,7 @@ local typeof = game and typeof or require "test/mock".typeof :: never local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) local get_scope = graph.get_scope -local add_cleanup = graph.add_cleanup +local push_cleanup = graph.push_cleanup local function helper(obj: any) return @@ -21,13 +21,13 @@ local function cleanup(value: unknown) local scope = get_scope() if not scope then - throw "cannot cleanup in a non-reactive scope" + throw "cannot cleanup outside a stable or reactive scope" end; assert(scope) if type(value) == "function" then - add_cleanup(scope, value :: () -> ()) + push_cleanup(scope, value :: () -> ()) else - add_cleanup(scope, helper(value)) + push_cleanup(scope, helper(value)) end end diff --git a/src/derive.luau b/src/derive.luau index 863fd98..fb824a4 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -2,21 +2,17 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) local create_node = graph.create_node -local set_owner = graph.set_owner -local track = graph.track -local assert_owning_scope = graph.assert_owning_scope +local push_child_to_scope = graph.push_child_to_scope +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node local function derive(source: () -> T): () -> T - local owner = assert_owning_scope() + local node = create_node(assert_stable_scope(), source, false :: any) - local node = create_node(false :: any, source) - - set_owner(node, owner) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end diff --git a/src/effect.luau b/src/effect.luau index bbe1669..3acab21 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -2,16 +2,12 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) local create_node = graph.create_node -local assert_owning_scope = graph.assert_owning_scope +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner local function effect(callback: (T) -> T, initial_value: T) - local owner = assert_owning_scope() + local node = create_node(assert_stable_scope(), callback, initial_value) - local node = create_node(initial_value, callback) - - set_owner(node, owner) evaluate_node(node) end diff --git a/src/graph.luau b/src/graph.luau index 2908e3d..31ca2d9 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -3,7 +3,7 @@ if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) -export type StartNode = { +export type SourceNode = { cache: T, [number]: Node } @@ -16,12 +16,11 @@ export type Node = { owned: { Node } | false, owner: Node | false, - parents: { StartNode }, + parents: { SourceNode }, [number]: Node -- children } --- reactive scope stack -local scopes = { n = 0 } :: { [number]: Node, n: number } +local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) local thread = coroutine.create(pcall) @@ -40,46 +39,37 @@ local function get_scope(): Node? return scopes[scopes.n] end -local function assert_owning_scope(): Node +local function assert_stable_scope(): Node local scope = get_scope() if not scope then local caller_name = debug.info(2, "n") - return throw(`cannot use {caller_name}() in a non-reactive scope`) + return throw(`cannot use {caller_name}() outside a stable or reactive scope`) elseif scope.effect then - throw("cannot create new reactive scope in a tracking reactive scope") + throw("cannot create a new reactive scope inside another reactive scope") end return scope end -local function add_child(parent: StartNode, child: Node) +local function push_child(parent: SourceNode, child: Node) table.insert(parent, child) table.insert(child.parents, parent) end -local function set_owner(node: Node, owner: Node) - node.owner = owner - if owner.owned then - table.insert(owner.owned, node) - else - owner.owned = { node } - end -end - -local function open_scope(node: Node) +local function push_scope(node: Node) local n = scopes.n + 1 scopes.n = n scopes[n] = node end -local function close_scope() +local function pop_scope() local n = scopes.n scopes.n = n - 1 scopes[n] = nil end -local function add_cleanup(node: Node, cleanup: () -> ()) +local function push_cleanup(node: Node, cleanup: () -> ()) if node.cleanups then table.insert(node.cleanups, cleanup) else @@ -87,34 +77,35 @@ local function add_cleanup(node: Node, cleanup: () -> ()) end end -local function run_cleanups(node: Node) +local function flush_cleanups(node: Node) if node.cleanups then for _, fn in next, node.cleanups do local ok, err: string? = pcall(fn) if not ok then throw(`cleanup error: {err}`) end end + table.clear(node.cleanups) end end local function find_and_swap_pop(t: { T }, v: T) - local idx = table.find(t, v) :: number + local i = table.find(t, v) :: number local n = #t - t[idx] = t[n] + t[i] = t[n] t[n] = nil end local function unparent(node: Node) local parents = node.parents - for i, parent in next, parents do + for i, parent in parents do find_and_swap_pop(parent, node) parents[i] = nil end end local function destroy(node: Node) - run_cleanups(node) + flush_cleanups(node) unparent(node) if node.owner then @@ -141,28 +132,28 @@ local function evaluate_node(node: Node) local cur_value = node.cache if flags.strict then - run_cleanups(node) + flush_cleanups(node) destroy_owned(node) - open_scope(node) + push_scope(node) local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) - close_scope() + pop_scope() if not ok then throw(new_value :: string) end node.cache = new_value :: T end - run_cleanups(node) + flush_cleanups(node) destroy_owned(node) - open_scope(node) + push_scope(node) local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) - close_scope() + pop_scope() if not ok then table.clear(update_queue) @@ -175,7 +166,7 @@ local function evaluate_node(node: Node) return cur_value ~= new_value end -local function queue_children(node: StartNode) +local function queue_children_for_update(node: SourceNode) local i = update_queue.n while node[1] do i += 1 @@ -198,7 +189,7 @@ local function flush_update_queue() --assert(node.effect) if node.owner and evaluate_node(node) then - queue_children(node) + queue_children_for_update(node) end update_queue[i] = false :: any @@ -210,9 +201,9 @@ local function flush_update_queue() _flushing = false end -local function update(root: StartNode) +local function update_descendants(root: SourceNode) local n0 = update_queue.n - queue_children(root) + queue_children_for_update(root) if flags.batch then return end @@ -223,7 +214,7 @@ local function update(root: StartNode) -- check if node is still owned in case destroyed after queued if node.owner and evaluate_node(node) then - queue_children(node) + queue_children_for_update(node) end update_queue[i] = false :: any -- false instead of nil to avoid sparse @@ -233,27 +224,37 @@ local function update(root: StartNode) update_queue.n = n0 end -local function track(node: StartNode) +local function push_child_to_scope(node: SourceNode) local scope = get_scope() if scope and scope.effect then -- do not track nodes with no effect - add_child(node, scope) + push_child(node, scope) end end -local function create_node(value: T, effect: false | (T) -> T): Node - return { +local function create_node(owner: false | Node, effect: false | (T) -> T, value: T): Node + local node: Node = { cache = value, effect = effect, cleanups = false, - owner = false, + owner = owner, owned = false, parents = {}, } + + if owner then + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end + end + + return node end -local function create_start_node(value: T): StartNode +local function create_source_node(value: T): SourceNode return { cache = value } end @@ -262,20 +263,19 @@ local function get_children(node: Node): { Node } end return table.freeze { - open_scope = open_scope, - close_scope = close_scope, + push_scope = push_scope, + pop_scope = pop_scope, evaluate_node = evaluate_node, get_scope = get_scope, - assert_owning_scope = assert_owning_scope, - add_cleanup = add_cleanup, - set_owner = set_owner, + assert_stable_scope = assert_stable_scope, + push_cleanup = push_cleanup, destroy = destroy, - run_cleanups = run_cleanups, - track = track, - update = update, - add_child = add_child, + flush_cleanups = flush_cleanups, + push_child_to_scope = push_child_to_scope, + update_descendants = update_descendants, + push_child = push_child, create_node = create_node, - create_start_node = create_start_node, + create_source_node = create_source_node, get_children = get_children, flush_update_queue = flush_update_queue, scopes = scopes diff --git a/src/maps.luau b/src/maps.luau index ef36209..e5fa332 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -4,15 +4,14 @@ local throw = require(script.Parent.throw) local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node -type StartNode = graph.StartNode +type SourceNode = graph.SourceNode local create_node = graph.create_node -local create_start_node = graph.create_start_node -local set_owner = graph.set_owner -local track = graph.track -local update = graph.update -local assert_owning_scope = graph.assert_owning_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local create_source_node = graph.create_source_node +local push_child_to_scope = graph.push_child_to_scope +local update_descendants = graph.update_descendants +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope local evaluate_node = graph.evaluate_node local destroy = graph.destroy @@ -28,14 +27,12 @@ local function check_primitives(t: {}) end local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = assert_owning_scope() - - local subowner = create_node(false, false) - set_owner(subowner, owner) + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) local input_cache = {} :: Map local output_cache = {} :: Map - local input_nodes = {} :: Map> + local input_nodes = {} :: Map> local remove_queue = {} :: { K } local scopes = {} :: Map> @@ -59,7 +56,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, table.clear(remove_queue) - open_scope(subowner) + push_scope(subowner) -- process new or changed values for i, v in next, data do @@ -67,23 +64,22 @@ local function indexes(input: () -> Map, transform: (() -> VI, if cv ~= v then if cv == nil then -- create new scope and run transform - local scope = create_node(false, false) + local scope = create_node(subowner, false, false) scopes[i] = scope :: Node - local node = create_start_node(v) + local node = create_source_node(v) - set_owner(scope, subowner) - open_scope(scope) + push_scope(scope) local ok, result = pcall(transform, function() - track(node) + push_child_to_scope(node) return node.cache end, i) - close_scope() + pop_scope() if not ok then - close_scope() -- subowner scope + pop_scope() -- subowner scope error(result, 0) end @@ -91,14 +87,14 @@ local function indexes(input: () -> Map, transform: (() -> VI, output_cache[i] = result else -- update source input_nodes[i].cache = v - update(input_nodes[i]) + update_descendants(input_nodes[i]) end input_cache[i] = v end end - close_scope() + pop_scope() local output_array = table.create(#scopes) for _, v in next, output_cache do @@ -109,29 +105,26 @@ local function indexes(input: () -> Map, transform: (() -> VI, return output_array end - local node = create_node(false :: any, function() + local node = create_node(owner, function() return update_children(input()) - end) - set_owner(node, owner) + end, false :: any) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = assert_owning_scope() - - local subowner = create_node(false, false) - set_owner(subowner, owner) + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) local cur_input_cache_up = {} :: Map local new_input_cache_up = {} :: Map local output_cache = {} :: Map - local input_nodes = {} :: Map> + local input_nodes = {} :: Map> local scopes = {} :: Map> local function update_children(data: Map) @@ -147,7 +140,7 @@ local function values(input: () -> Map, transform: (VI, () -> end end - open_scope(subowner) + push_scope(subowner) -- process data for i, v in next, data do @@ -156,23 +149,22 @@ local function values(input: () -> Map, transform: (VI, () -> local cv = cur_input_cache[v] if cv == nil then -- create new scope and run transform - local scope = create_node(false, false) + local scope = create_node(subowner, false, false) scopes[v] = scope :: Node - local node = create_start_node(i) + local node = create_source_node(i) - set_owner(scope, subowner) - open_scope(scope) + push_scope(scope) local ok, result = pcall(transform, v, function() - track(node) + push_child_to_scope(node) return node.cache end) - close_scope() + pop_scope() if not ok then - close_scope() -- subowner scope + pop_scope() -- subowner scope error(result, 0) end @@ -181,14 +173,14 @@ local function values(input: () -> Map, transform: (VI, () -> else -- update source if cv ~= i then input_nodes[v].cache = i - update(input_nodes[v]) + update_descendants(input_nodes[v]) end cur_input_cache[v] = nil end end - close_scope() + pop_scope() -- remove old values for v in next, cur_input_cache do @@ -212,15 +204,14 @@ local function values(input: () -> Map, transform: (VI, () -> return output_array end - local node = create_node(false :: any, function() + local node = create_node(owner, function() return update_children(input()) - end) - set_owner(node, owner) + end, false :: any) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end diff --git a/src/root.luau b/src/root.luau index 50a1c1c..33c9a0d 100644 --- a/src/root.luau +++ b/src/root.luau @@ -4,14 +4,14 @@ local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope local destroy = graph.destroy local refs = {} local function root(fn: (destroy: () -> ()) -> T...): T... - local node = create_node(false, false) + local node = create_node(false, false, false) refs[node] = true -- prevent gc of root node @@ -21,11 +21,11 @@ local function root(fn: (destroy: () -> ()) -> T...): T... destroy(node) end - open_scope(node) + push_scope(node) local result = { pcall(fn, destroy) } - close_scope() + pop_scope() if not result[1] then refs[node] = nil diff --git a/src/source.luau b/src/source.luau index dd633bb..e326815 100644 --- a/src/source.luau +++ b/src/source.luau @@ -2,18 +2,18 @@ if not game then script = require "test/relative-string" end local graph = require(script.Parent.graph) type Node = graph.Node -local create_start_node = graph.create_start_node -local track = graph.track -local update = graph.update +local create_source_node = graph.create_source_node +local push_child_to_scope = graph.push_child_to_scope +local update_descendants = graph.update_descendants export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source - local node = create_start_node(initial_value) + local node = create_source_node(initial_value) return function(...): T if select("#", ...) == 0 then -- no args were given - track(node) + push_child_to_scope(node) return node.cache end @@ -23,7 +23,7 @@ local function source(initial_value: T): Source end node.cache = v - update(node) + update_descendants(node) return v end end diff --git a/src/spring.luau b/src/spring.luau index cc92d9a..9e4f48a 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -24,14 +24,13 @@ Unsupported datatypes: local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node -type StartNode = graph.StartNode +type SourceNode = graph.SourceNode local create_node = graph.create_node -local create_start_node = graph.create_start_node -local assert_owning_scope = graph.assert_owning_scope +local create_source_node = graph.create_source_node +local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -local update = graph.update -local set_owner = graph.set_owner -local track = graph.track +local update_descendants = graph.update_descendants +local push_child_to_scope = graph.push_child_to_scope local UPDATE_RATE = 120 local TOLERANCE = 0.0001 @@ -146,11 +145,11 @@ setmetatable(vec6_to_type, invalid_type) -- maps spring data to its corresponding output node -- lifetime of spring data is tied to output node -local springs: { [SpringData]: StartNode } = {} +local springs: { [SpringData]: SourceNode } = {} setmetatable(springs, { __mode = "v" }) local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T - local owner = assert_owning_scope() + local owner = assert_stable_scope() -- https://en.wikipedia.org/wiki/Damping @@ -182,7 +181,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number source_value = false :: any, } - local output = create_start_node(false :: any) + local output = create_source_node(false :: any) local function updater_effect() local value = source() @@ -192,9 +191,8 @@ local function spring(source: () -> T, period: number?, damping_ratio: number return value end - local updater = create_node(false :: any, updater_effect) + local updater = create_node(owner, updater_effect, false :: any) - set_owner(updater, owner) evaluate_node(updater) -- set initial position to goal @@ -204,7 +202,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number output.cache = data.source_value return function() - track(output) + push_child_to_scope(output) return output.cache end end @@ -269,7 +267,7 @@ local function update_spring_sources() output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) end - update(output) + update_descendants(output) end for _, data in next, remove_queue do diff --git a/src/switch.luau b/src/switch.luau index 12fd376..99edd3c 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -3,20 +3,19 @@ if not game then script = require "test/relative-string" end local throw = require(script.Parent.throw) local graph = require(script.Parent.graph) type Node = graph.Node -type StartNode = graph.StartNode +type SourceNode = graph.SourceNode local create_node = graph.create_node local evaluate_node = graph.evaluate_node -local set_owner = graph.set_owner -local track = graph.track +local push_child_to_scope = graph.push_child_to_scope local destroy = graph.destroy -local assert_owning_scope = graph.assert_owning_scope -local open_scope = graph.open_scope -local close_scope = graph.close_scope +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope type Map = { [K]: V } local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = assert_owning_scope() + local owner = assert_stable_scope() return function(map) local last_scope: Node? @@ -38,28 +37,26 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () throw "map must map a value to a function" end - local new_scope = create_node(false, false) + local new_scope = create_node(owner, false, false) last_scope = new_scope :: Node - set_owner(new_scope, owner) - open_scope(new_scope) + push_scope(new_scope) local ok, result = pcall(component) - close_scope() + pop_scope() if not ok then error(result, 0) end return result end - local node = create_node(nil :: U?, update) + local node = create_node(owner, update, nil) - set_owner(node, owner) evaluate_node(node) return function() - track(node) + push_child_to_scope(node) return node.cache end end diff --git a/test/tests.luau b/test/tests.luau index c38c6a7..e874d78 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -35,28 +35,27 @@ vide.strict = false TEST("graph", function() local create_node = graph.create_node - local track = graph.track - local update = graph.update - local add_child = graph.add_child + local push_child_to_scope = graph.push_child_to_scope + local update_descendants = graph.update_descendants + local push_child = graph.push_child local get_scope = graph.get_scope - local open_scope = graph.open_scope - local close_scope = graph.close_scope - local set_owner = graph.set_owner + local push_scope = graph.push_scope + local pop_scope = graph.pop_scope local get_children = graph.get_children - local add_cleanup = graph.add_cleanup + local push_cleanup = graph.push_cleanup local destroy = graph.destroy - local function node(v: T?) - return create_node(v or false, function(x) return not x end) + local function node(owner: Node?, v: T?) + return create_node(owner or false, function(x) return not x end, v or false :: any) end - local function scope() - return create_node(false, false) + local function scope(owner: Node?) + return create_node(owner or false, false, false) end local function cleanup(fn: () -> ()) local node = assert(get_scope()) - add_cleanup(node, fn) + push_cleanup(node, fn) end do CASE "link nodes" @@ -64,12 +63,12 @@ TEST("graph", function() local b = node() local c = node() - open_scope(c) + push_scope(c) - track(a) - track(b) + push_child_to_scope(a) + push_child_to_scope(b) - close_scope() + pop_scope() CHECK(get_children(a)[1] == c) CHECK(get_children(b)[1] == c) @@ -78,33 +77,30 @@ TEST("graph", function() do CASE "rerun linked nodes" local root = node() local a = node() - local b = node() - local c = node() - - set_owner(b, root) - set_owner(c, root) + local b = node(root) + local c = node(root) local count = 0 local function effect(x) - track(a) - track(b) + push_child_to_scope(a) + push_child_to_scope(b) count += 1 return not x end c.effect = effect - open_scope(c) + push_scope(c) effect(c.cache) - close_scope() + pop_scope() CHECK(count == 1) - update(a) + update_descendants(a) CHECK(count == 2) - update(b) + update_descendants(b) CHECK(count == 3) end @@ -112,22 +108,18 @@ TEST("graph", function() -- a -> b -> d -- -> c local root = node() - local a, b, c, d = node(), node(), node(), node() - - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) + local a, b, c, d = node(), node(root), node(root), node(root) local b_cnt, c_cnt, d_cnt = 0, 0, 0 function b.effect(x) b_cnt += 1; return not x end function c.effect(x) c_cnt += 1; return not x end function d.effect(x) d_cnt += 1; return not x end - open_scope(b); track(a); close_scope() - open_scope(c); track(a); close_scope() - open_scope(d); track(b); track(c); close_scope() + push_scope(b); push_child_to_scope(a); pop_scope() + push_scope(c); push_child_to_scope(a); pop_scope() + push_scope(d); push_child_to_scope(b); push_child_to_scope(c); pop_scope() - update(a) + update_descendants(a) CHECK(b_cnt == 1) CHECK(c_cnt == 1) @@ -136,21 +128,17 @@ TEST("graph", function() do CASE "duplicate child on rerun" local root = node() - local a, b, c = node(), node(), node() - - set_owner(a, root) - set_owner(b, root) - set_owner(c, root) + local a, b, c = node(root), node(root), node(root) function c.effect(x) - track(a) - track(b) + push_child_to_scope(a) + push_child_to_scope(b) return not x end - open_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); close_scope() + push_scope(c); assert(type(c.effect) == "function" and c.effect)(NIL); pop_scope() - update(a) + update_descendants(a) CHECK(#get_children(a) == 1) CHECK(#get_children(b) == 1) @@ -159,13 +147,13 @@ TEST("graph", function() do CASE "case 1" -- construct graph - local items = node { "a", "b" } - local selected = node "a" + local items = node(nil, { "a", "b" }) + local selected = node(nil, "a") local root = scope() - local scope1 = scope() - local scope2 = scope() + local scope1 = scope(root) + local scope2 = scope(root) local items_updated @@ -180,41 +168,36 @@ TEST("graph", function() end) end - do open_scope(root) + do push_scope(root) clean "root" - items_updated = node() - track(items_updated) -- should not + items_updated = node(root) + push_child_to_scope(items_updated) -- should not - set_owner(items_updated, root) - do open_scope(items_updated) - track(items) + do push_scope(items_updated) + push_child_to_scope(items) - do open_scope(root) - set_owner(scope1, root) - do open_scope(scope1) + do push_scope(root) + do push_scope(scope1) clean "scope1" - bind1 = node() + bind1 = node(scope1) - set_owner(bind1, scope1) - do open_scope(bind1) + do push_scope(bind1) clean "bind1" - track(selected) - close_scope() end - close_scope() end + push_child_to_scope(selected) + pop_scope() end + pop_scope() end - set_owner(scope2, root) - do open_scope(scope2) + do push_scope(scope2) clean "scope2" - bind2 = node() - set_owner(bind2, scope2) - do open_scope(bind2) + bind2 = node(scope2) + do push_scope(bind2) clean "bind2" - track(selected) - close_scope() end - close_scope() end - close_scope() end - close_scope() end - close_scope() end + push_child_to_scope(selected) + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end + pop_scope() end -- verify graph @@ -267,7 +250,7 @@ TEST("graph", function() end do CASE "nodes garbage collection" - local wref = weak { node(1) } + local wref = weak { node(nil, 1) } destroy(wref[1]) gc() CHECK(not wref[1]) @@ -294,30 +277,23 @@ TEST("graph", function() ^ depth=1 - _, _ <- attempt to update nothing + _, _ <- attempt to update_descendants nothing ^ ]] - local a, b, c, d, e, f = node(), node(), node(), node(), node(), node() - local root = node() - set_owner(a, root) - set_owner(b, root) - set_owner(c, root) - set_owner(d, root) - set_owner(e, root) - set_owner(f, root) + local a, b, c, d, e, f = node(root), node(root), node(root), node(root), node(root), node(root) function b.effect(x) - update(d) + update_descendants(d) return not x end - add_child(a, b); add_child(a, c) - add_child(d, e); add_child(d, f) + push_child(a, b); push_child(a, c) + push_child(d, e); push_child(d, f) - update(a) + update_descendants(a) CHECK(true) end @@ -1924,7 +1900,7 @@ TEST("read()", wrap_root(function() CHECK(read(src) == 1) end - do CASE "track source" + do CASE "push_child_to_scope source" local src = source(0) local count = 0 From 62f1c3a20aa6c4629fb99436c3543776206137e0 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:40:01 +0100 Subject: [PATCH 19/94] Update docs --- docs/.vitepress/config.ts | 7 +- docs/tut/crash-course/1-introduction.md | 16 +-- docs/tut/crash-course/10-cleanup.md | 45 ++---- docs/tut/crash-course/11-control-flow.md | 136 +++--------------- .../{13-actions.md => 12-actions.md} | 0 docs/tut/crash-course/12-property-nesting.md | 120 ---------------- .../{14-strict-mode.md => 13-strict-mode.md} | 0 .../{15-concepts.md => 14-concepts.md} | 0 docs/tut/crash-course/2-creation.md | 7 +- docs/tut/crash-course/3-components.md | 4 +- docs/tut/crash-course/4-source.md | 13 +- docs/tut/crash-course/5-effect.md | 10 +- docs/tut/crash-course/6-scope.md | 43 +++--- docs/tut/crash-course/7-stateful-component.md | 2 - docs/tut/crash-course/8-implicit-effect.md | 16 +-- docs/tut/crash-course/9-derived-source.md | 14 +- 16 files changed, 86 insertions(+), 347 deletions(-) rename docs/tut/crash-course/{13-actions.md => 12-actions.md} (100%) delete mode 100644 docs/tut/crash-course/12-property-nesting.md rename docs/tut/crash-course/{14-strict-mode.md => 13-strict-mode.md} (100%) rename docs/tut/crash-course/{15-concepts.md => 14-concepts.md} (100%) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 51b477a..5c32428 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -49,10 +49,9 @@ export default withMermaid({ { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, - { text: "Property Nesting", link: "/tut/crash-course/12-property-nesting" }, - { text: "Actions", link: "/tut/crash-course/13-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/14-strict-mode" }, - { text: "Concepts Summary", link: "/tut/crash-course/15-concepts" } + { text: "Actions", link: "/tut/crash-course/12-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } ] }, { diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 261ce83..983f51b 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -4,22 +4,12 @@ This is a tutorial that introduces the concepts and usage of Vide. Vide is heavily inspired by [Solid](https://www.solidjs.com/). -This tutorial assumes familiarity with Luau and Roblox UI. - ## Why Vide? -Creating UI is complicated, slow, and tedious. - -Vide tries to simplify and speed up this process by providing a declarative and -reactive of style programming, which lets you focus more on designing the UI -itself and not having to manually update or reparent UI instances. +Vide provides a reactive and declarative API to simplify managing UI. Some of the main focuses behind Vide's design choices: -- Minimal syntax. +- Minimal syntax - Complete typechecking -- Independence from instances. - -As with most declarative libraries, there is an initial learning curve to -understand the concepts and usage. This tutorial tries to comprehensively -cover these concepts and usage, more so than you need just to use it. +- Independence from instances diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index 474f1f2..a863093 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -2,48 +2,33 @@ Sometimes you may need to do some cleanup when destroying a component or after a side-effect from a source update. Vide provides a function `cleanup()` which -is used to queue a cleanup callback for the next time a reactive scope is rerun -or destroyed, or when a stable scope is destroyed. +is used to queue a callback for the next time a reactive scope is rerun or +destroyed, or when a stable scope is destroyed. ```lua -local mount = vide.mount +local root = vide.root local source = vide.source -local cleanup = vide.cleanup +local effect = vide.effect -local function Timer() - local count = source(0) +local count = source(0) - local con = game:GetService("RunService").Heartbeat:Connect(function(dt) - count(count() + dt) + +local destroy = root(function(destroy) + effect(function() + local x = count() + cleanup(function() print(x) end) end) - cleanup(function() - con:Disconnect() - end) + cleanup(function() print "root destroyed" end) - return create "TextButton" { - Position = UDim2.fromOffset(300, 300), - Size = UDim2.fromOffset(200, 50), - - Text = function() - return "seconds: " .. math.floor(count()) - end, - } -end - -local instance, destroy = root(function(destroy) - local instance = Timer() - return instance, destroy + return destroy end) -wait(5) - -destroy() -- all queued cleanups are ran, heartbeat connection disconnected +count(1) -- prints "0" +count(2) -- prints "1" +destroy() -- prints "2" and "root destroyed" ``` -In the above example, this allows us to disconnect the heartbeat connection -when the scope responsible for creating the timer component is destroyed. - ::: tip Roblox instances do not need to be explicitly destroyed for their memory to be freed, they only need to be parented to `nil`. So there is no diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index 246d65b..b206915 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -5,119 +5,26 @@ resulting from source updates. Vide provides functions to help you do this, known as *control flow* functions. These functions return new sources, which hold the instances to be displayed. - -Control flow functions run their components in a new stable scope, which can -be destroyed independently of the stable scope that called the control flow -function. This means parts of your app can be independently created and -destroyed. - -## switch() - -`switch()` condtionally displays one instance at a time. It uses a table to map -a source value to a component. - -```lua -local source = vide.source -local switch = vide.switch - -local function Button(props: { - Text: string, - Activated: () -> () -}) - local hovered = source(false) - - return create "TextButton" { - Text = props.Text, - Activated = props.Activated, - - TextColor3 = function() - return hovered() and Color3.new(1, 1, 1) or Color3.new(.7, .7, .7) - end, - - MouseEnter = function() hovered(true) end, - MouseLeave = function() hovered(false) end - } -end - -local function JoinMenu() - local joined = source(false) - - local function JoinButton() - return Button { - Text = "Join", - Activated = function() joined(true) end - } - end - - local function LeaveButton() - return Button { - Text = "Leave" - Activated = function() joined(false) end - } - end - - return create "Frame" { - switch(joined) { - [true] = LeaveButton, - [false] = JoinButton - } - } -end -``` - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root["root scope"] - direction LR - joined --> switch -.- subroot - - subgraph subroot["switch scope"] - direction LR - effect["TextColor3 effect"] - end -end -``` - -A `switch()` call creates a new effect and a new stable scope as seen in the -above graph. Whenever `menu` updates, it causes the `switch` effect to run, -which will destroy and recreate the switch scope with the new component. - -This will also destroy the internal effect that the button uses to highlight -itself when it is hovered, each time the switch is rerun. +The new sources can be used in `create()` to update the children of a container +instance. ## indexes() -Often, you will have a table of values with each value displayed in a similar -manner. Rather than manually looping over each value to generate a corresponding -UI element, `indexes()` allows you to create elements each corresponding to a -table index, to display the value at that index. +`indexes()` *maps* each table index to a new UI element that can +update to display the current value at that index. Each table index is given a +single corresponding UI element. ```lua -local todoList = source { +local list = source { "finish the crash course", - "star vide's GitHub" + "star Vide's GitHub" } local function TodoList(props: { list: () -> Array }) return create "Frame" { create "UIListLayout" {}, - indexes(todoList, function(todo, i) + indexes(list, function(todo, i) return create "TextLabel" { Text = function() return i .. ": " .. todo() @@ -129,13 +36,13 @@ local function TodoList(props: { list: () -> Array }) } end -TodoList { list = todoList } +TodoList { list = list } ``` -For each index in the given source table, the given function will be called -with: +For each index in the given source table, the given function to `indexes()` will +be run in a new stable scope with: -1. a source containing the value of the index +1. a source containing the value at the index 2. the index itself When the value at an index is changed, the function is not reran. Instead, the @@ -143,12 +50,9 @@ given source for that index is updated. Any time the input source table is updated, the given function will be ran for any newly added indexes, while any removed indexes (indexes now with a `nil` -value), will have its corresponding reactive scope destroyed to clean up that -element. +value), will have its corresponding stable scope destroyed. + -`indexes()` is said to *map* each table index to a new UI element that can -update to display the current value at that index. Each table index is given a -single corresponding UI element. The reactive graph for the above example: @@ -183,8 +87,8 @@ subgraph root ["root scope"] end ``` -One thing to note regarding table sources, is that when you edit a table in a -source, you must set that table again to actually update the source. +When you edit a table in a source, you must set that table again to actually +update the source. ```lua local src = source { 1, 2 } @@ -192,11 +96,3 @@ local data = src() table.insert(data, 3) -- no effects will run src(data) -- effects will run ``` - -Together, these control flow functions cover the majority of cases where you -need to dynamically create and destroy parts of your UI. - -If you need to do something that these control flow functions cannot, you can -always use `mount()` within an effect to dynamically create and destroy -components on your own terms. Just remember to use `cleanup()` to unmount when -the effect reruns. diff --git a/docs/tut/crash-course/13-actions.md b/docs/tut/crash-course/12-actions.md similarity index 100% rename from docs/tut/crash-course/13-actions.md rename to docs/tut/crash-course/12-actions.md diff --git a/docs/tut/crash-course/12-property-nesting.md b/docs/tut/crash-course/12-property-nesting.md deleted file mode 100644 index d8c44ce..0000000 --- a/docs/tut/crash-course/12-property-nesting.md +++ /dev/null @@ -1,120 +0,0 @@ -# Nested Properties - -Often when creating components from existing components, you can find yourself -repetitively passing through properties such as size or position. - -Example below: - -```lua -function Background(props: { - Color: Color3, - AnchorPoint: UDim2, - Position: UDim2, - Size: UDim2 -}) - return create "Frame" { - Color = props.Color - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size - } -end - -function Menu(props: { - Color = props.Color - AnchorPoint: UDim2, - Position: UDim2, - Size: UDim2 -}) - return Background { - Color = props.Color, - AnchorPoint = props.AnchorPoint, - Position = props.Position, - Size = props.Size - } -end -``` - -One way this can be avoided is by using *property nesting*. In Vide, passing a -table value inside `props` has special semantics. Any key with a table value is -not assigned like a property, instead the table is iterated and processed just -like the outer table is. Any properties in the nested table will be assigned -to the instance just the same. - -Below is an example of how you can use this to pass groups of similar properties -together such as position and size, while also using typechecking. - -```lua -type Layout = { - Layout = { - Position: UDim2?, - Size: UDim2?, - AnchorPoint: Vector2? - } -} - -function Background(props: Layout & { Color: Color3 }) - return create "Frame" { - Color = props.Color, - props.Layout - } -end - -function Menu(props: Layout & { Color: Color3 }) - return Background { - Color = props.Color, - Layout = props.Layout - } -end -``` - -Here we created a nested group with the key `Layout` that can accept -layout-related properties. Any name could be chosen for the key. -This allows us to write much more concise syntax that is also typecheckable. - -In another example we use a key named `Children` to pass arrays of instances to -be parented. - -```lua -type Children = { - -- also can optionally pass a source that returns an array of children too - Children = Array | () -> Array -} - -local function List(props: Children & Layout) - return create "Frame" { - props.Children, - props.Layout, - create "UIListLayout" {} - } -end - -List { - Layout = { - Position = UDim2.new() - }, - - Children = { - create "TextLabel" { Text = "1" }, - create "TextLabel" { Text = "2" } - } -} -``` - -Deeper nested properties are guaranteed to be set after shallower nested -properties, this can be used to create overridable default properties. - -```lua -local function List(props: Children & Layout) - return create "Frame" { - props.Children, - props.Layout, - - -- can be overriden by `props.Layout` - AnchorPoint = Vector2.new(0.5, 0), - Position = UDim2.fromScale(0.5, 0), - - create "UIListLayout" {} - } -end -``` diff --git a/docs/tut/crash-course/14-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md similarity index 100% rename from docs/tut/crash-course/14-strict-mode.md rename to docs/tut/crash-course/13-strict-mode.md diff --git a/docs/tut/crash-course/15-concepts.md b/docs/tut/crash-course/14-concepts.md similarity index 100% rename from docs/tut/crash-course/15-concepts.md rename to docs/tut/crash-course/14-concepts.md diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 3cc4fd5..6dace52 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -2,11 +2,8 @@ Instances are created using `create()`. -`create()` returns a constructor for a class which then takes a table of -properties to assign when creating a new instance for that class. - -Luau allows us to omit parentheses `()` when calling functions with string or -table literals which is recommended to use for brevity. +Parentheses `()` can be omitted when calling functions with string or +table literals which is recommended for brevity. ```lua local create = vide.create diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index e3cc730..8e5890f 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -34,12 +34,12 @@ end return Button ``` -```lua [App.luau] +```lua [Menu.luau] local create = vide.create local Button = require(Button) -local function App() +local function Menu() return create "ScreenGui" { Button { Position = UDim2.fromOffset(200, 200), diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index c356b6b..08da45c 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -1,7 +1,7 @@ # Sources -Sources are special objects that store a single value. They are the core of -Vide's reactivity. They are called sources because they act as sources of data. +Sources are special objects that store a single value and are the core of +Vide's reactivity. A source can be created using `source()`. @@ -20,8 +20,7 @@ by calling it with no arguments. count(count() + 1) -- increment count by 1 ``` -Sources can be *derived* by wrapping them in functions. A wrapped source -effectively becomes a new source. +Sources can be *derived* by wrapping them in functions. ```lua local count = source(0) @@ -35,7 +34,5 @@ count(1) print(text()) -- "count: 1" ``` -Sources on their own aren't very special, the above can be achieved with plain -variables. The real use for sources become apparent when used in combination -with *effects*. Similar to a signal and connection, a source and effect allows -you to do things like automatically updating UI when a source is updated. +While the above can be achieved with plain variables, the use for sources will +be obvious in the next part. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 275a2e6..5e6043c 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,8 +1,7 @@ # Effects Effects are functions that are ran in response to source updates. They are -called effects because they cause *side-effects* when reacting to source -updates. +A source and effect is analogous to a signal and connection. Effects are created using `effect()`. @@ -21,11 +20,10 @@ count(1) -- "count: 1" printed ``` -The callback given to `effect()` is ran immediately in a *reactive scope*. Any -source read from inside a reactive scope will be tracked, so when any of those -sources update, the effect will be reran too. +Any source read inside an effect is tracked and will rerun the effect when +that source is updated. -Reactive scopes also track derived sources, it doesn't matter how deeply nested +Derived sources are also tracked, it doesn't matter how deeply nested inside a function a source is. ```lua diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index 6148364..fa6ea8f 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -1,25 +1,29 @@ # Scopes -Vide operates on the concept of scopes. Vide scopes come in two flavors: -stable and reactive. +Just like how a signal's connection may need to be disconnected, a source's +effect also may need to be disconnected. -The three main rules for scopes are: +But the disconnecting of many signals and connections is tedious and verbose. +Vide instead operates on the concept of scopes which provides a much cleaner +API, given that you follow a few rules. -- Stable scopes never rerun. -- Reactive scopes will rerun on source updates. -- A reactive scope cannot be created within another reactive scope. +Scopes come in two flavors; stable and reactive. -Reactive scopes cannot be created on their own - they must be created within -a stable scope so that it can be tracked and later destroyed when it is -no longer needed. +- All scopes must be created within another scope with the exception of `root()` +- Stable scopes never rerun +- Reactive scopes can rerun +- A reactive scope cannot be created within another reactive scope -This is the purpose of `root()`, which creates an initial stable scope, which -all other reactive scopes, such as ones created by `effect()`, can stem from. +`effect()` creates a reactive scope. +`root()` creates a stable scope. -When this root reactive scope is destroyed, it will destroy any effects created -within it, ensuring everything is cleaned up properly. +Whenever a scope is destroyed, any scope created within that scope is also +destroyed, and so on. This is why all scopes must be created within another +scope, except `root()` which is used to create the initial scope that you can +manually destroy. ```lua +local root = vide.root local source = vide.source local effect = vide.effect @@ -33,9 +37,9 @@ local function setup() return count end -setup() -- will error since effect() was not called within a stable scope +setup() -- will error since effect() tries to create a reactive scope outside of a stable scope -local count = vide.root(setup) -- runs +local count = root(setup) -- ok since effect() was called within a stable scope count(1) -- prints "1" ``` @@ -87,7 +91,7 @@ subgraph root end ``` -When the root reactive scope created by `root()` is destroyed, the `effect` +When the stable `root()` is destroyed, the reactive `effect()` scope will also be destroyed since it was created within it. This is important because you may have an effect that updates the property of a @@ -96,7 +100,6 @@ memory. The effect being destroyed will remove this reference, allowing the instance to be garbage collected. You don't need to worry about ensuring all your effects are created within a -root reactive scope, since you should be creating all your UI and corresponding -effects within a top-level `root()` call that puts all your UI together. So it -is safe to assume that any effect you create will be created under this top -level scope. Vide will prevent you from accidently doing otherwise anyways. +stable scope, since you should be creating all your UI and effects within a +single top-level `root()` call that puts all your UI together, making it safe to +assume any effect created will be created under this stable scope. diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 427d0ee..493f9bc 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -1,7 +1,5 @@ # Stateful Components -A stateful component is a component that stores some data internally. - Stateful components in Vide are created using sources and effects - sources to store the data, and effects to display the data. diff --git a/docs/tut/crash-course/8-implicit-effect.md b/docs/tut/crash-course/8-implicit-effect.md index 60d7823..bbd92d4 100644 --- a/docs/tut/crash-course/8-implicit-effect.md +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -1,7 +1,7 @@ # Implicit Effects -Explicitly creating effects to update properties can be tedious. Vide provides a -way to *implicitly* create an effect to update properties. +Explicitly creating effects to update properties is tedious. You can +*implicitly* create an effect to update properties instead. ```lua local create = vide.create @@ -25,18 +25,14 @@ end This example is equivalent to the example seen on the previous page. Instead of explicitly creating an effect, assigning a (non-event) property a -function will implicitly create an effect to update that property anytime a -source used within is updated. - -Just like effects, the function is ran immediately in a reactive scope to set -the property initially and determine what sources are being used. +function will implicitly create an effect to update that property. ## Children Children can also be set in a similar manner. A source passed as a child (passed with a number key instead of string key) can return an instance or an array of -instances. Vide will automatically unparent removed instances and parent new -instances when that source's stored instances change. +instances. An effect is automatically created to unparent removed instances and +parent new instances on source update. ```lua local items = source { @@ -57,5 +53,5 @@ items { create "TextLabel" { Text = "C" } } --- this will automatically unparent the text label "A", and parent the labels "B" and "C". +-- this will automatically unparent the text label "A", and parent the labels "B" and "C" ``` diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index c1e3669..ffcdaa9 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -36,14 +36,13 @@ source(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will -run a function in a new reactive scope only when a dependent source has updated. -Reading this derived source multiple times will just return a cached result from -when it last updated. +run a function in a reactive scope only when a source used inside updated. +Reading this derived source multiple times will just return a cached result. ```lua local source = vide.source -local derive = vide.derive local effect = vide.effect +local derive = vide.derive local count = source(0) @@ -87,6 +86,7 @@ end ``` Deriving a source in this manner is similar to creating an effect to update -another source. You should never manually do this using an effect however, -improper usage could accidently create infinite loops in the reactive graph. -Always favour deriving when you need one source to update based on another. +another source. You should never manually do this using an effect however. +Improper usage could accidently create infinite loops in the reactive graph. +Always favour deriving when you need one source to update based on another +source. From 000def5bc252a1d6d458fcb142a01eeb7f0ce7f0 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:49:30 +0100 Subject: [PATCH 20/94] Update docs --- docs/.vitepress/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5c32428..7708fdf 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -45,7 +45,7 @@ export default withMermaid({ { text: "Effects", link: "/tut/crash-course/5-effect" }, { text: "Scopes", link: "/tut/crash-course/6-scope" }, { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, - { text: "Property Binding", link: "/tut/crash-course/8-implicit-effect" }, + { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, From 14f8d38a35fa671e65adeb7cbb35b42a62adb7c1 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 3 Jul 2024 15:34:16 +0100 Subject: [PATCH 21/94] Update docs --- docs/api/animation.md | 4 ++-- docs/api/creation.md | 8 ++++---- docs/api/reactivity-core.md | 11 +++-------- docs/api/reactivity-flow.md | 24 ++++++++++++------------ docs/api/reactivity-utility.md | 11 +++++------ docs/api/strict-mode.md | 4 ++-- docs/tut/crash-course/12-actions.md | 7 +++++-- docs/tut/crash-course/2-creation.md | 2 +- 8 files changed, 34 insertions(+), 37 deletions(-) diff --git a/docs/api/animation.md b/docs/api/animation.md index 16e48d7..6481cc3 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -18,8 +18,8 @@ Returns a new source with a value always moving torwards the input source value. - **Details** - The output source value is updated every step based on the input source - value. + An effect is created to update the new source every frame based on the input + source value. The movement is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). diff --git a/docs/api/creation.md b/docs/api/creation.md index 46d54c3..539967d 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -18,10 +18,10 @@ target instance. The result of the function is applied to a target in the same way properties are using `create()`. - The function is ran in a new reactive scope, just like + The function is ran in a new stable scope, just like [root()](reactivity-core.md#root). - Returns a function that when called will destroy the reactive scope. + Returns a function that when called will destroy the stable scope. - **Example** @@ -170,7 +170,7 @@ A wrapper for `action()` to listen for property changes. Will run the given callback any time the property is changed, as well as when the action is initially run. - The changed connection is disconnected when the reactive scope the action is - ran in is destroyed. + The changed connection is disconnected when the scope the action is ran in + is destroyed. Runs with an action priority of 1. diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 652c80a..8b860d0 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -42,11 +42,6 @@ Creates a new source with the given value. Calling the returned source with no argument will return its stored value, calling with an argument will set a new value. - Reading from the source from within a reactive scope will cause changes - to that source to be tracked and anything depending on it to update. - - Sources can be created outside of reactive scopes. - - **Example** ```lua @@ -69,10 +64,10 @@ Runs a side-effect in a new reactive scope on source update. - **Details** - Any time a source referenced in the callback is changed, the callback will + Any time a source referenced in the callback is updated, the callback will be reran. - The callback is ran to initially ran on first call to find dependent sources. + The callback is ran once immediately. - **Example** @@ -108,7 +103,7 @@ Derives a new source in a new reactive scope from existing sources. Anytime its value is recalculated it is also cached, subsequent calls will retun this cached value until it recalculates again. - The callback is ran to initially ran on first call to find dependent sources. + The callback is ran once immediately. - **Example** diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md index 47dafa8..6870367 100644 --- a/docs/api/reactivity-flow.md +++ b/docs/api/reactivity-flow.md @@ -18,12 +18,12 @@ Shows one of two components depending on an input source. Returns a source holding an instance of the currently shown component. When the input source changes from a falsey to a truthy value, the - component will be reran under a new reactive scope. If it changes from a - truthy to falsey value, the reactive scope the component was created in will + component will be reran under a new stable scope. If it changes from a + truthy to falsey value, the stable scope the component was created in will be destroyed, and the returned source will output `nil`, or a fallback component if given. - The fallback component is also ran under a new reactive scope, and destroyed + The fallback component is also ran under a new stable scope, and destroyed when the input source switches back to truthy. ## switch() @@ -41,10 +41,10 @@ Shows one of a set of components depending on an input source and a mapping tabl Returns a source holding an instance of the currently shown component. When the input source changes, the new value will be used to lookup a given - mapping table to get a component, which will be ran under a new reactive - scope. If the input source changes, the reactive scope the component was + mapping table to get a component, which will be ran under a new stable + scope. If the input source changes, the stable scope the component was created in will be destroyed, and a new component created under a new - reactive scope. If no component is found for an input value, the switch will + stable scope. If no component is found for an input value, the switch will output `nil`. - **Example** @@ -82,9 +82,9 @@ Maps each index in a table source to an object. When the input source changes, each *index* in the new table is compared with the last input table. - - For any new index, the `transform` function is ran under a new reactive + - For any new index, the `transform` function is ran under a new stable scope to produce a new instance. - - For any removed index, the reactive scope for that index is destroyed. + - For any removed index, the stable scope for that index is destroyed. - Unchanged indexes are untouched. The transform function is called only ever *once* for each index in the @@ -142,9 +142,9 @@ Maps each value in a table source to an object. When the input source changes, each *value* in the new table is compared with the last input table. Similar to `indexes()` but for values instead of indexes. - - For any new value, the `transform` function is ran under a new reactive + - For any new value, the `transform` function is ran under a new stable scope to produce a new instance. - - For any removed value, the reactive scope for that value is destroyed. + - For any removed value, the stable scope for that value is destroyed. - Unchanged values are untouched. The transform function is only ever called *once* for each value in the @@ -203,7 +203,7 @@ Maps each value in a table source to an object. - 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. + has primitive values. It maps an index to a UI element. e.g. - List of character or weapon stats. @@ -213,6 +213,6 @@ Maps each value in a table source to an object. result in less property updates and less re-renders. One case to note is that `values()` works nicely when animating re-ordering of instances, since the value is not destroyed when indexes are changed, and the source index - can easily be put through a spring. + can be used to animate a change in position for the UI element. -------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index ee82067..7f08657 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -2,7 +2,7 @@ ## cleanup() -Runs a callback anytime a reactive scope is reran or destroyed. +Runs a callback anytime a scope is reran or destroyed. - **Type** @@ -31,8 +31,7 @@ Runs a callback anytime a reactive scope is reran or destroyed. ## untrack() -Runs a given function where any sources read will not be tracked by a reactive -scope. +Runs a given function in a new stable scope. - **Type** @@ -42,8 +41,8 @@ scope. - **Details** - Updates made to a source passed to `untrack()` will not cause updates to - anything depending on that source. + Can be used inside a reactive scope to read from sources you do not want + tracked by the reactive scope. - **Example** @@ -76,7 +75,7 @@ read can still be tracked inside a reactive scope. ## batch() Runs a given function where any source updates made within the function do not -trigger effects until after the function runs. +trigger effects until after the function finishes running. - **Type** diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index a7fca48..570108b 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -22,12 +22,12 @@ Currently, strict mode will: 6. Checks for duplicate nested properties at same depth. 7. Better error reporting and stack traces + creation traces of property bindings. -By rerunning derived sources and effects twice each time they update,it helps +By rerunning derived sources and effects twice each time they update, it helps ensure that derived source computations are pure, and that any cleanups made in derived sources or effects are done correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, -which strict mode can catch. +which strict mode will catch. As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index 06b431d..176c12e 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -24,6 +24,7 @@ action used to listen for property changes: ```lua local action = vide.action +local effect = vide.effect local cleanup = vide.cleanup local function changed(prop: string, callback: (new) -> ()) @@ -44,9 +45,11 @@ local instance = create "TextBox" { changed("Text", output) } -instance.Text = "foo" +effect(function() + print(output()) +end) -print(output()) -- "foo" +instance.Text = "foo" -- "foo" will be printed from the effect ``` The source `output` will be updated with the new property value any time it is diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 6dace52..5e76173 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -3,7 +3,7 @@ Instances are created using `create()`. Parentheses `()` can be omitted when calling functions with string or -table literals which is recommended for brevity. +table literals for brevity. ```lua local create = vide.create From 7bae2517cd952815a7846c8c006da322cba9dbe0 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:37:26 +0100 Subject: [PATCH 22/94] Improve error reporting --- src/bind.luau | 37 +++++-------------------- src/graph.luau | 67 +++++++++++++++++++++++++-------------------- src/root.luau | 2 +- src/throw.luau | 8 ++---- src/trace.luau | 29 -------------------- test/benchmark.luau | 22 --------------- test/tests.luau | 4 +-- 7 files changed, 50 insertions(+), 119 deletions(-) delete mode 100644 src/trace.luau diff --git a/src/bind.luau b/src/bind.luau index c6e8ab7..dd34ce3 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,35 +1,12 @@ if not game then script = require "test/relative-string" end -local trace = require(script.Parent.trace) -local flags = require(script.Parent.flags) local graph = require(script.Parent.graph) type Node = graph.Node local create_node = graph.create_node local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node -function create_binding(updater: (T) -> T, binding: T) - if flags.strict then - -- track bind creation trace - local fn = updater - local bind_trace = debug.traceback(nil, trace()-1) - updater = function(...) - local ok, result = xpcall(fn, function(err: string) - return err - end, ...) - - 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 - +function create_implicit_effect(updater: (T) -> T, binding: T) evaluate_node(create_node(assert_stable_scope(), updater, binding)) end @@ -39,7 +16,7 @@ type PropertyBinding = { source: () -> unknown } -local function update_property(p: PropertyBinding) +local function update_property_effect(p: PropertyBinding) (p.instance :: any)[p.property] = p.source() return p end @@ -49,7 +26,7 @@ type ParentBinding = { parent: () -> Instance } -local function update_parent(p: ParentBinding) +local function update_parent_effect(p: ParentBinding) p.instance.Parent = p.parent() return p end @@ -61,7 +38,7 @@ type ChildrenBinding = { children: () -> Instance | { Instance } } -local function update_children(p: ChildrenBinding) +local function update_children_effect(p: ChildrenBinding) local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update @@ -94,7 +71,7 @@ end return { property = function(instance, property, source) - return create_binding(update_property, { + return create_implicit_effect(update_property_effect, { instance = instance, property = property, source = source @@ -102,14 +79,14 @@ return { end, parent = function(instance, parent) - return create_binding(update_parent, { + return create_implicit_effect(update_parent_effect, { instance = instance, parent = parent }) end, children = function(instance, children) - return create_binding(update_children, { + return create_implicit_effect(update_children_effect, { instance = instance, cur_children_set = {}, new_children_set = {}, diff --git a/src/graph.luau b/src/graph.luau index 31ca2d9..a90dd6c 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -23,13 +23,14 @@ export type Node = { local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) - local thread = coroutine.create(pcall) - local resume_ok, run_ok, result = coroutine.resume(thread, fn, arg) + local thread = coroutine.create(xpcall) + local function efn(err: string) return debug.traceback(err, 3) end + local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) assert(resume_ok) if coroutine.status(thread) ~= "dead" then - return false, "attempt to yield in reactive scope" + return false, debug.traceback(thread, "attempt to yield in reactive scope") end return run_ok, result @@ -129,41 +130,47 @@ end local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) - local cur_value = node.cache - if flags.strict then + local ok, cur_value, new_value + for i = 1, 2 do + cur_value = node.cache + + flush_cleanups(node) + destroy_owned(node) + + push_scope(node) + ok, new_value = ycall(node.effect :: (T) -> T, cur_value) + pop_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`effect stacktrace:\n{new_value :: string}`) + end + + node.cache = new_value :: T + end + + return cur_value ~= new_value + else + local cur_value = node.cache + flush_cleanups(node) destroy_owned(node) push_scope(node) - - local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) - + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) pop_scope() - - if not ok then throw(new_value :: string) end - node.cache = new_value :: T + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`effect stacktrace:\n{new_value}\n`) + end + + node.cache = new_value + return cur_value ~= new_value end - - flush_cleanups(node) - destroy_owned(node) - - push_scope(node) - - local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) - - pop_scope() - - if not ok then - table.clear(update_queue) - update_queue.n = 0 - throw(`side-effect error from source update\n{new_value}`) - end - - node.cache = new_value - - return cur_value ~= new_value end local function queue_children_for_update(node: SourceNode) diff --git a/src/root.luau b/src/root.luau index 33c9a0d..2a1eb92 100644 --- a/src/root.luau +++ b/src/root.luau @@ -29,7 +29,7 @@ local function root(fn: (destroy: () -> ()) -> T...): T... if not result[1] then refs[node] = nil - throw(`mount error\n{result[2]}`) + throw(`error while running root():\n\n{result[2]}`) end return unpack(result :: any, 2) diff --git a/src/throw.luau b/src/throw.luau index 70b7973..954f3e2 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -1,9 +1,7 @@ if not game then script = require "test/relative-string" end -local trace = require(script.Parent.trace) - -local function throw(msg): any - error(msg, trace() - 1) +local function VIDE_ASSERT(msg): any + error(msg, 0) end -return throw +return VIDE_ASSERT diff --git a/src/trace.luau b/src/trace.luau deleted file mode 100644 index 04672ff..0000000 --- a/src/trace.luau +++ /dev/null @@ -1,29 +0,0 @@ --- returns path to file as an array with each directory --- accounts for Roblox and Luau contexts -local function get_path(s) - if string.sub(s, #s - 4, #s) == ".luau" then - s = string.sub(s, 1, #s - 5) - end - - return string.split(s, string.match(s, "%w+/") and "/" or ".") -end - --- get directory of vide root -local root do - local path = get_path(debug.info(1, "s")) - root = path[#path - 1] -end - --- finds the first stack depth outside of any vide library function -return function(): number - local stack = 1 - - local path = get_path(debug.info(stack, "s")) - - while path[#path] == root or path[#path - 1] == root do - stack += 1 - path = get_path(debug.info(stack, "s")) - end - - return stack -end diff --git a/test/benchmark.luau b/test/benchmark.luau index bee8c68..c35b651 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -26,7 +26,6 @@ end local N = 2^18 -- 262144 - TITLE "sources" BENCH("create source", function() @@ -449,27 +448,6 @@ end) N *= 1024 -TITLE "cleanup" - -ROOT_BENCH("register new cleanup", function() - local cleanup = cleanup - - local cleaner = function() end - - local callers = {} - - for i = 1, N do - callers[i] = function(fn, v) - fn(v) - return i -- return unique upvalue to ensure unique closure - end - end - - for i = 1, START(N) do - callers[i](cleanup, cleaner) - end -end) - TITLE "aggregate" do diff --git a/test/tests.luau b/test/tests.luau index e874d78..02e2e6c 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -218,8 +218,8 @@ TEST("graph", function() do local c = get_children(selected) CHECK(#c == 2) - CHECK(table.find(c, bind1)) - CHECK(table.find(c, bind2)) + CHECK(table.find(c, bind1 :: any)) + CHECK(table.find(c, bind2 :: any)) end do From 4f3fccd3bb3f1f2c7aa9fbda8d6ff9f9b41fc8e7 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 17 Jul 2024 23:28:43 +0100 Subject: [PATCH 23/94] Fix `root()` not showing full error trace --- src/root.luau | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/root.luau b/src/root.luau index 2a1eb92..f8fc983 100644 --- a/src/root.luau +++ b/src/root.luau @@ -23,7 +23,8 @@ local function root(fn: (destroy: () -> ()) -> T...): T... push_scope(node) - local result = { pcall(fn, destroy) } + local function efn(err: string) return debug.traceback(err, 3) end + local result = { xpcall(fn, efn, destroy) } pop_scope() From 2dc2814d76139fb8fe927547b69f90cc066f8098 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Fri, 19 Jul 2024 11:21:49 +0100 Subject: [PATCH 24/94] Fix regression with derived sources --- CHANGELOG.md | 8 ++++++++ src/graph.luau | 9 +++++---- test/tests.luau | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d16e5..89cf8e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- +## Unreleased + +### Fixed + +- Error stack traces being lost. + +-------------------------------------------------------------------------------- + ## [0.2.0] - 2023-11-22 ### Added diff --git a/src/graph.luau b/src/graph.luau index a90dd6c..f35b9b6 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -131,15 +131,16 @@ local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) if flags.strict then - local ok, cur_value, new_value + local initial_value = node.cache + for i = 1, 2 do - cur_value = node.cache + local cur_value = node.cache flush_cleanups(node) destroy_owned(node) push_scope(node) - ok, new_value = ycall(node.effect :: (T) -> T, cur_value) + local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) pop_scope() if not ok then @@ -151,7 +152,7 @@ local function evaluate_node(node: Node) node.cache = new_value :: T end - return cur_value ~= new_value + return initial_value ~= node.cache else local cur_value = node.cache diff --git a/test/tests.luau b/test/tests.luau index 02e2e6c..c0f69d1 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2261,6 +2261,27 @@ TEST("strict", wrap_root(function() src(not src()) CHECK(count == 4) end + + do CASE "effect using derived source" + local input = source(true) + + local output = derive(function() + return input() + end) + + local count = 0 + + effect(function() + output() + count += 1 + end) + + CHECK(count == 2) + + input(false) + + CHECK(count == 4) + end end)) local ok = FINISH() From 31da86c30b277f0516305eaa7fe1790af174b85a Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 20 Jul 2024 18:08:04 +0100 Subject: [PATCH 25/94] Allow `indexes()` and `values()` to return functions --- src/maps.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps.luau b/src/maps.luau index e5fa332..23c1eb3 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -21,7 +21,7 @@ local function check_primitives(t: {}) if not flags.strict then return end for _, v in next, t do - if type(v) == "table" or type(v) == "userdata" then continue end + if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end throw("table source map cannot return primitives") end end From e1751fb8d0cf6e76f9abe9d911da2ba644b82d7f Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 27 Jul 2024 02:03:06 +0100 Subject: [PATCH 26/94] Fix GitHub action --- .github/workflows/unit-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a6cd4dc..4a9db7f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -14,7 +14,7 @@ jobs: uses: robinraju/release-downloader@v1.6 with: repository: Roblox/luau - latest: true + tag: "0.620" fileName: luau-ubuntu.zip out-file-path: bin From 72e5fbb6fe58166278f1b356e7d52d573d34034e Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Mon, 19 Aug 2024 06:56:00 -0700 Subject: [PATCH 27/94] Round UDim offset values (#30) --- src/spring.luau | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index 9e4f48a..bd1a990 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -114,11 +114,11 @@ local vec6_to_type = { end :: Vec6ToType, UDim = function(v) - return UDim.new(v.X, v.Y) + return UDim.new(v.X, math.round(v.Y)) end :: Vec6ToType, UDim2 = function(a, b) - return UDim2.new(a.X, a.Y, a.Z, b.X) + return UDim2.new(a.X, math.round(a.Y), a.Z, math.round(b.X)) end :: Vec6ToType, Vector2 = function(v) From b85419088c1a0e4e96e090b1062f3841e41c6ee8 Mon Sep 17 00:00:00 2001 From: alicesaidhi <166900055+alicesaidhi@users.noreply.github.com> Date: Tue, 20 Aug 2024 13:40:29 +0200 Subject: [PATCH 28/94] allow fragments in implicit effects for children (#29) * fix fragments in effects * add test case * more descriptive test --- src/apply.luau | 3 ++- src/bind.luau | 13 +++++++++++-- test/tests.luau | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/apply.luau b/src/apply.luau index 13e8cab..0f4586e 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -11,6 +11,7 @@ local graph = require(script.Parent.graph) type Node = graph.Node type Array = { V } +type ArrayOrV = {ArrayOrV} | V type Map = { [K]: V } local free_caches: { @@ -124,7 +125,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end elseif type(property) == "number" then if type(value) == "function" then - bind.children(instance, value :: () -> Instance | Array) -- bind children + bind.children(instance, value :: () -> ArrayOrV) -- bind children elseif type(value) == "table" then if is_action(value) then table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer diff --git a/src/bind.luau b/src/bind.luau index dd34ce3..3a016a6 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -38,6 +38,7 @@ type ChildrenBinding = { children: () -> Instance | { Instance } } +type ArrayOrV = V | { V } local function update_children_effect(p: ChildrenBinding) local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update @@ -48,8 +49,14 @@ local function update_children_effect(p: ChildrenBinding) new_children = { new_children } end - if new_children then - for _, child in next, new_children :: { Instance } do + local function process_child(child: ArrayOrV) + if type(child) == "table" then + for _, child in next, child do + process_child(child) + end + else + if new_child_set[child] then return end -- stops redundant reparenting + 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 @@ -59,6 +66,8 @@ local function update_children_effect(p: ChildrenBinding) end end + process_child(new_children) + for child in next, cur_children_set do child.Parent = nil -- unparent all children that weren't in the new children set end diff --git a/test/tests.luau b/test/tests.luau index c0f69d1..adda8da 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -945,6 +945,42 @@ TEST("create()", wrap_root(function() CHECK((f2 :: any).a == 2) end + do CASE "nested children effect" + local a = create "Frame" { Name = "a" } + local b = create "Frame" { Name = "b" } + local c = create "Frame" { Name = "c" } + local d = create "Frame" { Name = "d" } + local e = create "Frame" { Name = "e" } + + local children = source { + a, + { b, c, { d } }, + { { e } } + } + + local obj = create "Frame" { + children + } + + CHECK(obj:FindFirstChild("a")) + CHECK(obj:FindFirstChild("b")) + CHECK(obj:FindFirstChild("c")) + CHECK(obj:FindFirstChild("d")) + CHECK(obj:FindFirstChild("e")) + + children { + b, + { c, a }, + { { d } } + } + + CHECK(obj:FindFirstChild("a")) + CHECK(obj:FindFirstChild("b")) + CHECK(obj:FindFirstChild("c")) + CHECK(obj:FindFirstChild("d")) + CHECK(not obj:FindFirstChild("e")) + end + do CASE "garbage collection test" local wref From fbe2f01bb99e7f7744d5039f36c494044f044883 Mon Sep 17 00:00:00 2001 From: alicesaidhi <166900055+alicesaidhi@users.noreply.github.com> Date: Tue, 20 Aug 2024 13:44:56 +0200 Subject: [PATCH 29/94] fix recursive queue flush (#28) * fix recursive queue flush * remove print from test * swap order of flush * simplified test case and remove error * write more in-depth tests * change approach flush_update_queue * double diamond test case * fix indent * change naming of function * subsequent updates do not batch test case * simplify test case --- src/batch.luau | 11 +-- src/graph.luau | 19 ++--- test/tests.luau | 205 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 16 deletions(-) diff --git a/src/batch.luau b/src/batch.luau index 1951789..45ab038 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -6,17 +6,18 @@ local graph = require(script.Parent.graph) local function batch(setter: () -> ()) local already_batching = flags.batch + local from - flags.batch = true + if not already_batching then + flags.batch = true + from = graph.get_update_queue_length() + end local ok, err: string? = pcall(setter) if not already_batching then flags.batch = false - - if not already_batching then - graph.flush_update_queue() - end + graph.flush_update_queue(from) end if not ok then throw(`error occured while batching updates: {err}`) end diff --git a/src/graph.luau b/src/graph.luau index f35b9b6..34deff4 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -184,14 +184,12 @@ local function queue_children_for_update(node: SourceNode) update_queue.n = i end -local _flushing = false -local function flush_update_queue() - assert(not _flushing, "recursive queue flush occured") -- todo - _flushing = true +local function get_update_queue_length() + return update_queue.n +end - local n0 = 0 - - local i = n0 + 1 +local function flush_update_queue(from: number) + local i = from + 1 while i <= update_queue.n do local node = update_queue[i] --assert(node.effect) @@ -203,10 +201,8 @@ local function flush_update_queue() update_queue[i] = false :: any i += 1 end - - update_queue.n = n0 - - _flushing = false + + update_queue.n = from end local function update_descendants(root: SourceNode) @@ -286,5 +282,6 @@ return table.freeze { create_source_node = create_source_node, get_children = get_children, flush_update_queue = flush_update_queue, + get_update_queue_length = get_update_queue_length, scopes = scopes } diff --git a/test/tests.luau b/test/tests.luau index adda8da..fe2778d 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1842,6 +1842,7 @@ end)) TEST("batch()", wrap_root(function() local source = vide.source local derive = vide.derive + local effect = vide.effect local batch = vide.batch do CASE "evaluation deferred" @@ -1920,6 +1921,210 @@ TEST("batch()", wrap_root(function() CHECK(b2() == 3) CHECK(b3() == 4) end + + do CASE "subsequent updates do not batch" + + local a = source(0) + local b = source(0) + local c = source(0) + local d_n = 0 + + effect(function() + b() + c() + d_n += 1 + end) + + effect(function() + b(a()) + c(a()) + end) + + batch(function() + a(1) + end) + + CHECK(d_n == 3) + + end + + do CASE "recursive queue flush diamond A,B,C,D" + --[[ + + a > b > d + > c > + + ]] + + local a = source(0) + + local b = source(0) + local c = source(0) + local d = source(0) + + local count = { b = 0, c = 0, d = 0 } + effect(function() + batch(function() + b(a() % 2 == 0 and 1 or 0) + c(a() * 2) + end) + count.b += 1 + count.c += 1 + end) + + effect(function() + batch(function() + d(b() + c()) + end) + count.d += 1 + end) + + a(1) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(d() == 2) + + a(3) + CHECK(count.b == 3) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(d() == 6) + end + + do CASE "recursive queue flush diamond A,B,C,D,E" + --[[ + where b and c batches d + + a > b > e + > c > d > + + ]] + + local a = source(0) + + local b = source(0) + local c = source(0) + local d = source(0) + local e = source(0) + + local count = { b = 0, c = 0, d = 0, e = 0 } + effect(function() + batch(function() + b(a() % 2 == 0 and 1 or 0) + c(a() * 2) + end) + count.b += 1 + count.c += 1 + end) + + effect(function() + batch(function() + d(c() * 2) + end) + count.d += 1 + end) + + effect(function() + batch(function() + e(b() + d()) + end) + count.e += 1 + end) + + CHECK(e() == 1) + + a(1) + + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(count.e == 3) + CHECK(e() == 4) + + a(3) + CHECK(count.b == 3) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(count.e == 4) + CHECK(e() == 12) + + end + + do CASE "recursive queue flush diamond A,B,C,D,E,F,G" + --[[ + + a > b > d > E > G + > c ^ > F + + ]] + + local a = source(0) + + local b = source(0) + local c = source(0) + local d = source(0) + + local e = source(0) + local f = source(0) + local g = source(0) + + local count = { b = 0, c = 0, d = 0, e = 0, f = 0, g = 0 } + effect(function() + batch(function() + b(a() % 2 == 0 and 1 or 0) + c(a() * 2) + end) + count.b += 1 + count.c += 1 + end) + + effect(function() + batch(function() + d(b() + c()) + end) + count.d += 1 + end) + + effect(function() + batch(function() + e(d() % 2 == 0 and 1 or 0) + f(d() * 2) + end) + count.e += 1 + count.f += 1 + end) + + effect(function() + batch(function() + g(e() + f()) + end) + count.g += 1 + end) + + a(1) + CHECK(count.b == 2) + CHECK(count.c == 2) + CHECK(count.d == 2) + CHECK(count.e == 2) + CHECK(count.f == 2) + CHECK(count.g == 2) + CHECK(d() == 2) + CHECK(g() == 5) + + a(3) + CHECK(count.b == 3) + CHECK(count.c == 3) + CHECK(count.d == 3) + CHECK(count.e == 3) + CHECK(count.f == 3) + CHECK(count.g == 3) + CHECK(d() == 6) + CHECK(g() == 13) + + + end + end)) TEST("read()", wrap_root(function() From 83db00a0734a711e96c817546b1462c0e43166a2 Mon Sep 17 00:00:00 2001 From: EwDev <74792428+ewd3v@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:11:47 +0200 Subject: [PATCH 30/94] allow changing the current value of a spring (similar to sources) (#36) * allow changing the current value of a spring (similar to sources) * use spaces instead of tabs for identing --- src/spring.luau | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index bd1a990..a01ac1c 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -201,9 +201,27 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- set output to goal output.cache = data.source_value - return function() - push_child_to_scope(output) - return output.cache + return function(...) + if select("#", ...) == 0 then -- no args were given + push_child_to_scope(output) + return output.cache + end + + -- set current position to value + local v = ... :: T + data.x0_123, data.x0_456 = type_to_vec6[typeof(v)](v) + + -- reset velocity + data.v_123 = ZERO + data.v_456 = ZERO + + -- schedule spring + springs[data] = output + + -- set output to value + output.cache = v + + return v end end From 8142acd1c11856a2a6d41436838d7d83ce0c1d9b Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 6 Oct 2024 15:20:04 +0100 Subject: [PATCH 31/94] Add `context()` --- CHANGELOG.md | 4 ++ docs/api/reactivity-core.md | 6 +- docs/api/reactivity-utility.md | 41 +++++++++++++ src/context.luau | 75 ++++++++++++++++++++++++ src/graph.luau | 13 +++++ src/init.luau | 5 ++ test/benchmark.luau | 58 +++++++++++++++++++ test/tests.luau | 102 +++++++++++++++++++++++++++++++++ 8 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/context.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cf8e7..5ba48b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- `context()` + ### Fixed - Error stack traces being lost. diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 8b860d0..93c2263 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -34,7 +34,11 @@ Creates a new source with the given value. - **Type** ```lua - function source(value: T): (T?) -> T + function source(value: T): Source + + type Source = + () -> T -- get + & (T) -> () -- set ``` - **Details** diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 7f08657..824fe35 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -90,4 +90,45 @@ trigger effects until after the function finishes running. only cause the effect to run once after the batch call ends instead of after each time a source is updated. +## context() + +Creates a new context. + +- **Type** + + ```lua + function context(default: T): Context + + type Context = + () -> T -- get + & (T, () -> ()) -> () -- set + ``` + +- **Details** + + Calling `context()` returns a new context function. + Call this function with no arguments to get the context value. + Call this function with a value and a callback to set a new context with the + given value. + +- **Example** + + ```lua + local theme = context() + + local function Button() + print(theme()) + end + + root(function() + theme("light", function() + Button() -- prints "light" + + theme("dark", function() + Button() -- prints "dark" + end) + end) + end) + ``` + -------------------------------------------------------------------------------- diff --git a/src/context.luau b/src/context.luau new file mode 100644 index 0000000..fc0e8c1 --- /dev/null +++ b/src/context.luau @@ -0,0 +1,75 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_node = graph.create_node +local get_scope = graph.get_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local set_context = graph.set_context + +export type Context = (() -> T) & ((T, () -> ()) -> ()) + +local nil_symbol = newproxy() +local count = 0 + +local function context(...: T): Context + count += 1 + local id = count + + local has_default = select("#", ...) > 0 + local default_value = ... + + return function(...) + local scope: Node? | false = get_scope() + + if select("#", ...) == 0 then -- get + while scope do + local ctx = scope.context + + if not ctx then + scope = scope.owner + continue + end + + local value = (ctx :: { unknown })[id] + + if value == nil then + scope = scope.owner + continue + end + + return (if value ~= nil_symbol then value else nil) :: T + end + + if has_default ~= nil then + return default_value + else + throw("attempt to get context when no context is set and no default context is set") + end + else -- set + if not scope then return throw("attempt to set context outside of a vide scope") end + + local value, component = ... + + local new_scope = create_node(scope, false, false) + set_context(new_scope, id, if value == nil then nil_symbol else value) + + push_scope(new_scope) + + local function efn(err: string) return debug.traceback(err, 3) end + local ok, result = xpcall(component, efn) + + pop_scope() + + if not ok then + throw(`error while running context:\n\n{result}`) + end + end + + return nil :: any + end +end + +return context diff --git a/src/graph.luau b/src/graph.luau index 34deff4..9648180 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -13,6 +13,8 @@ export type Node = { effect: ((T) -> T) | false, cleanups: { () -> () } | false, + context: { [number]: unknown } | false, + owned: { Node } | false, owner: Node | false, @@ -241,6 +243,8 @@ local function create_node(owner: false | Node, effect: false | (T) -> T effect = effect, cleanups = false, + context = false, + owner = owner, owned = false, @@ -266,6 +270,14 @@ local function get_children(node: Node): { Node } return { unpack(node) } :: { Node } end +local function set_context(node: Node, key: number, value: unknown) + if node.context then + node.context[key] = value + else + node.context = { [key] = value } + end +end + return table.freeze { push_scope = push_scope, pop_scope = pop_scope, @@ -283,5 +295,6 @@ return table.freeze { get_children = get_children, flush_update_queue = flush_update_queue, get_update_queue_length = get_update_queue_length, + set_context = set_context, scopes = scopes } diff --git a/src/init.luau b/src/init.luau index 840f7f9..64a4a55 100644 --- a/src/init.luau +++ b/src/init.luau @@ -16,6 +16,7 @@ local cleanup = require(script.cleanup) local untrack = require(script.untrack) local read = require(script.read) local batch = require(script.batch) +local context = require(script.context) local switch = require(script.switch) local show = require(script.show) local indexes, values = require(script.maps)() @@ -26,6 +27,9 @@ local throw = require(script.throw) local flags = require(script.flags) export type Source = source.Source +export type source = Source +export type Context = context.Context +export type context = Context local function step(dt: number) if game then @@ -63,6 +67,7 @@ local vide = { untrack = untrack, read = read, batch = batch, + context = context, -- animations spring = spring, diff --git a/test/benchmark.luau b/test/benchmark.luau index c35b651..05fe3ef 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -4,11 +4,14 @@ local BENCH, START = testkit.benchmark() local vide = require "src/init" local source = vide.source local derive = vide.derive +local effect = vide.effect local indexes = vide.indexes local values = vide.values local batch = vide.batch local cleanup = vide.cleanup +local untrack = vide.untrack local create = vide.create +local context = vide.context assert(not vide.strict) @@ -446,6 +449,61 @@ ROOT_BENCH("values() all remove", function() src(data) end) +TITLE "context()" + +ROOT_BENCH("set context", function() + local ctx = context() + + for i = 1, START(N) do + ctx(i, function() end) + end +end) + +ROOT_BENCH("get context (depth=1)", function() + local ctx = context() + + local function run() + for i = 1, START(N) do + ctx() + end + end + + ctx(1, function() + run() + end) +end) + +local depth = 10 +ROOT_BENCH(`get context (depth={depth})`, function() + + local ctx = context() + + local function run() + for i = 1, START(N) do + ctx() + end + end + + local function nest_effect(fn) + untrack(function() + effect(fn) + return nil + end) + end + + local f = run + for i = 1, depth - 1 do + local f_inner = f + f = function() + nest_effect(f_inner) + end + end + + ctx(1, function() + f() + end) +end) + N *= 1024 TITLE "aggregate" diff --git a/test/tests.luau b/test/tests.luau index fe2778d..55642ff 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2155,6 +2155,108 @@ TEST("read()", wrap_root(function() end end)) +TEST("context()", function() + local root = vide.root + local context = vide.context + local effect = vide.effect + local untrack = vide.untrack + local show = vide.show + + do CASE "set context" + local ctx = context() + + root(function() + ctx(1, function() + CHECK(ctx() == 1) + + effect(function() + CHECK(ctx() == 1) + end) + end) + end) + end + + do CASE "set context outside of scope" + local ctx = context() + + local ok = pcall(function() + ctx(1, function() end) + end) + + CHECK(not ok) + end + + do CASE "get default context" + local ctx = context(1) + + CHECK(ctx() == 1) + + root(function() + ctx(2, function() + CHECK(ctx() == 2) + end) + + CHECK(ctx() == 1) + end) + end + + do CASE "context cascade" + local ctx = context(1) + local ctx2 = context() + + root(function() + ctx(2, function() + ctx2(true, function() + show(function() return true end, function() + ctx(3, function() + effect(function() + CHECK(ctx() == 3) + untrack(function() + effect(function() + CHECK(ctx() == 3) + end) + CHECK(ctx2() == true) + return {} + end) + end) + CHECK(ctx() == 3) + end) + CHECK(ctx() == 2) + return {} + end) + end) + CHECK(ctx() == 2) + end) + + CHECK(ctx() == 1) + end) + end + + do CASE "nil context" + local ctx = context(nil) + + root(function() + CHECK(ctx() == nil) + ctx(nil, function() + CHECK(ctx() == nil) + ctx(true :: any, function() + CHECK(ctx() == true) + ctx(nil, function() + effect(function() + untrack(function() + effect(function() + CHECK(ctx() == nil) + end) + return {} + end) + end) + end) + end) + end) + end) + end +end) + TEST("nested effects cases", function() local vide = require "src/init" local source = vide.source From 82eec61c45c0c13f521a243ef56d1936076d66ba Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 6 Oct 2024 15:37:05 +0100 Subject: [PATCH 32/94] Make `root()` return destructor automatically --- CHANGELOG.md | 6 +++++- docs/api/reactivity-core.md | 11 ++++------- docs/tut/advanced/nested-scoping.md | 3 +-- docs/tut/crash-course/10-cleanup.md | 4 +--- docs/tut/crash-course/6-scope.md | 9 ++++----- src/mount.luau | 3 +-- src/root.luau | 6 +++--- test/tests.luau | 14 +++++++------- 8 files changed, 26 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba48b1..3d5b4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- `context()` +- `context()`. + +### Changed + +- `root()` now returns its destructor as the first value by default. ### Fixed diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 93c2263..861e0e1 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -14,18 +14,15 @@ Creates and runs a function in a new stable scope. - **Type** ```lua - function root(fn: (destroy: () -> ()) -> T...): T... + function root(fn: (() -> ()) -> T...): (() -> (), T...) ``` - **Details** - Returns the result of the given function. + Returns a function to destroy the root scope. Also passes this function as + the first argument into its callback. - Creates a new stable scope, where creation of effects can be tracked and - properly disposed of. - - A function to destroy the root is passed into the callback, which will run - any cleanups and allow derived sources created to garbage collect. + All values returned by the callback are also returned following the destructor. ## source() diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md index fb31c15..c5e6b4b 100644 --- a/docs/tut/advanced/nested-scoping.md +++ b/docs/tut/advanced/nested-scoping.md @@ -81,9 +81,8 @@ mount(function() effect(function() if toggled() then - local destroy = root(function(destroy) + local destroy = root(function() Counter() - return destroy end) cleanup(destroy) end diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index a863093..f6ca5f3 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -13,15 +13,13 @@ local effect = vide.effect local count = source(0) -local destroy = root(function(destroy) +local destroy = root(function() effect(function() local x = count() cleanup(function() print(x) end) end) cleanup(function() print "root destroyed" end) - - return destroy end) count(1) -- prints "0" diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index fa6ea8f..2853fff 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -43,21 +43,20 @@ local count = root(setup) -- ok since effect() was called within a stable scope count(1) -- prints "1" ``` -The scope created by `root()` can be destroyed by calling the function it passes -into the given function. +The scope created by `root()` can be destroyed. ```lua -local function setup(destroy) +local function setup() local count = source(0) effect(function() print(count()) end) - return count, destroy + return count end -local count, destroy = root(setup) +local destroy, count = root(setup) count(1) -- prints "1" diff --git a/src/mount.luau b/src/mount.luau index 315925e..b9d0ace 100644 --- a/src/mount.luau +++ b/src/mount.luau @@ -4,10 +4,9 @@ local root = require(script.Parent.root) local apply = require(script.Parent.apply) local function mount(component: () -> T, target: Instance?): () -> () - return root(function(destroy) + return root(function() local result = component() if target then apply(target, { result }) end - return destroy end) end diff --git a/src/root.luau b/src/root.luau index f8fc983..14bfa52 100644 --- a/src/root.luau +++ b/src/root.luau @@ -10,7 +10,7 @@ local destroy = graph.destroy local refs = {} -local function root(fn: (destroy: () -> ()) -> T...): T... +local function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) local node = create_node(false, false, false) refs[node] = true -- prevent gc of root node @@ -33,7 +33,7 @@ local function root(fn: (destroy: () -> ()) -> T...): T... throw(`error while running root():\n\n{result[2]}`) end - return unpack(result :: any, 2) + return destroy, unpack(result :: any, 2) end -return root :: ((fn: (destroy: () -> ()) -> T...) -> T...) & ((fn: (destroy: () -> ()) -> ()) -> ()) +return root :: (fn: (destroy: () -> ()) -> T...) -> (() -> (), T...) diff --git a/test/tests.luau b/test/tests.luau index 55642ff..3fbdbf4 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -892,7 +892,7 @@ TEST("create()", wrap_root(function() end do CASE "parent bound to source" - local wref, destroy = vide.root(function(destroy) + local _, wref, destroy = vide.root(function(destroy) local frame = create "Frame" { Name = "Parent" } local parent = source(frame :: Frame?) @@ -1228,7 +1228,7 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local output = vide.root(function() + local _, output = vide.root(function() local output = indexes(input, function(v, i) count[i] += 1 return v @@ -1690,7 +1690,7 @@ TEST("untrack()", wrap_root(function() local input = source(0) - local output, destroy = root(function(destroy) + local _, output, destroy = root(function(destroy) local output = derive(function() outer_count += 1 @@ -1820,7 +1820,7 @@ TEST("changed()", wrap_root(function() end do CASE "connection disconnected" - local text, destroy = root(function(destroy) + local _, text, destroy = root(function(destroy) local output = source(nil) return create "TextLabel" { @@ -2397,7 +2397,7 @@ TEST("graph edge cases", wrap_root(function() do CASE "do not destroy children" local parent = source(0) - local + local _, destroy, parent_to_destroy, update_parent_to_destroy @@ -2440,7 +2440,7 @@ TEST("graph edge cases", wrap_root(function() -- child B reevaluates due to already being queued -- parent destroys, destroys child B - uh oh - local + local _, destroy_parent, parent, update_parent @@ -2452,7 +2452,7 @@ TEST("graph edge cases", wrap_root(function() src end) - local destroy_child, _child_B = function() end, nil + local _, destroy_child, _child_B = nil, function() end, nil local count_A = 0 From c4e1396684e92607f99f9c6f2bcbabf0a8f05376 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 6 Oct 2024 18:12:25 +0100 Subject: [PATCH 33/94] Fix typos --- docs/tut/crash-course/14-concepts.md | 4 ++-- docs/tut/crash-course/9-derived-source.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tut/crash-course/14-concepts.md b/docs/tut/crash-course/14-concepts.md index 0b9dc0c..24ca703 100644 --- a/docs/tut/crash-course/14-concepts.md +++ b/docs/tut/crash-course/14-concepts.md @@ -10,7 +10,7 @@ Stores a single value that can be updated. Created with `source()`. -# Derived Source +## Derived Source A new source composed of other sources. @@ -46,7 +46,7 @@ Created by: Reactive scopes do track sources and will rerun when those sources update. -New reactive scopes cannot be created within a reactive scope, but stable scopes +Reactive scopes cannot be created within a reactive scope, but stable scopes can. ## Scope Owners diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index ffcdaa9..12a89b2 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -32,7 +32,7 @@ end effect(function() text() end) effect(function() text() end) -source(1) -- prints "ran" x2 +count(1) -- prints "ran" x2 ``` To avoid this, you can use `derive()` to derive a new source instead. This will @@ -54,7 +54,7 @@ end) effect(function() text() end) effect(function() text() end) -source(1) -- prints "ran" x1 +count(1) -- prints "ran" x1 ``` `derive()` must also be called within a stable scope, just like `effect()`. From 93a2a2c6317fb77c2fd288692ac8c5d2c5875bd8 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 6 Oct 2024 18:14:53 +0100 Subject: [PATCH 34/94] Make `root()` destroy scope if error occurs --- CHANGELOG.md | 1 + src/root.luau | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5b4e8..fd2f9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Error stack traces being lost. +- `root()` now destroys the scope automatically if an error occurs during call. -------------------------------------------------------------------------------- diff --git a/src/root.luau b/src/root.luau index 14bfa52..bc5904d 100644 --- a/src/root.luau +++ b/src/root.luau @@ -29,7 +29,7 @@ local function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) pop_scope() if not result[1] then - refs[node] = nil + destroy() throw(`error while running root():\n\n{result[2]}`) end From 1d565262e1fc03d4bdaee38fa09ced626b575b4e Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 6 Oct 2024 18:16:31 +0100 Subject: [PATCH 35/94] Bump version to `0.3.0` --- CHANGELOG.md | 2 +- src/init.luau | 2 +- wally.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2f9c9..6c7c39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## Unreleased +## [0.3.0] - 2024-10-06 ### Added diff --git a/src/init.luau b/src/init.luau index 64a4a55..d2872c7 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,6 +1,6 @@ -------------------------------------------------------------------------------- -- vide.luau --- v0.2.0 +-- v0.3.0 -------------------------------------------------------------------------------- if not game then script = require "test/relative-string" end diff --git a/wally.toml b/wally.toml index b1387e5..a22ab5d 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.2.0" +version = "0.3.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] From 6ead93088ad383d6e1c3d2e70386e04a3c635f4d Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 9 Oct 2024 02:12:25 +0100 Subject: [PATCH 36/94] Make context functions return result --- CHANGELOG.md | 8 ++++++++ src/context.luau | 6 ++++-- test/tests.luau | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7c39f..805f529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- +## Unreleased + +### Added + +- Context functions now also return results. + +-------------------------------------------------------------------------------- + ## [0.3.0] - 2024-10-06 ### Added diff --git a/src/context.luau b/src/context.luau index fc0e8c1..6c571d0 100644 --- a/src/context.luau +++ b/src/context.luau @@ -9,7 +9,7 @@ local push_scope = graph.push_scope local pop_scope = graph.pop_scope local set_context = graph.set_context -export type Context = (() -> T) & ((T, () -> ()) -> ()) +export type Context = (() -> T) & ((T, () -> U) -> U) local nil_symbol = newproxy() local count = 0 @@ -21,7 +21,7 @@ local function context(...: T): Context local has_default = select("#", ...) > 0 local default_value = ... - return function(...) + return function(...): any -- todo: fix type error local scope: Node? | false = get_scope() if select("#", ...) == 0 then -- get @@ -66,6 +66,8 @@ local function context(...: T): Context if not ok then throw(`error while running context:\n\n{result}`) end + + return result end return nil :: any diff --git a/test/tests.luau b/test/tests.luau index 3fbdbf4..45aa41f 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2192,10 +2192,12 @@ TEST("context()", function() CHECK(ctx() == 1) root(function() - ctx(2, function() + local v = ctx(2, function() CHECK(ctx() == 2) + return ctx() end) + CHECK(v == 2) CHECK(ctx() == 1) end) end From f7dac3f63a485de1cc3c34834a55ddbb8056f21b Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 9 Oct 2024 02:23:06 +0100 Subject: [PATCH 37/94] Bump version to `0.3.1` --- CHANGELOG.md | 3 ++- src/init.luau | 5 ++++- wally.toml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 805f529..17e7087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## Unreleased +## [0.3.1] - 2024-10-09 ### Added - Context functions now also return results. +- `version` table with current version. -------------------------------------------------------------------------------- diff --git a/src/init.luau b/src/init.luau index d2872c7..3bcc7ad 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,8 +1,9 @@ -------------------------------------------------------------------------------- -- vide.luau --- v0.3.0 -------------------------------------------------------------------------------- +local version = { major = 0, minor = 3, patch = 1 } + if not game then script = require "test/relative-string" end local root = require(script.root) @@ -50,6 +51,8 @@ local stepped = game and game:GetService("RunService").Heartbeat:Connect(functio end) local vide = { + version = version, + -- core root = root, mount = mount, diff --git a/wally.toml b/wally.toml index a22ab5d..4896b08 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.3.0" +version = "0.3.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] From f8e84f9f8dd2c7ce0dcad06903ddc6af30c1051b Mon Sep 17 00:00:00 2001 From: alicesaidhi <166900055+alicesaidhi@users.noreply.github.com> Date: Sun, 3 Nov 2024 18:32:19 +0100 Subject: [PATCH 38/94] Improve Documentation Site (#41) * update css and snippets * Add banner * improve home page * Update Banner * cleanup * Update font to JetBrains Mono * Add a quick look to Home * Simplify Home Page * Removed banner, removed copyright notice, reduced home page --- README.md | 2 +- docs/.vitepress/config.ts | 155 ++++++++++-------- docs/.vitepress/theme/home.css | 19 +++ docs/.vitepress/theme/index.js | 12 +- docs/.vitepress/theme/vars.css | 49 +++++- docs/api/animation.md | 2 +- docs/api/creation.md | 16 +- docs/api/reactivity-core.md | 14 +- docs/api/reactivity-flow.md | 14 +- docs/api/reactivity-utility.md | 16 +- docs/api/strict-mode.md | 2 +- docs/index.md | 14 +- docs/package.json | 26 ++- docs/public/full_logo.svg | 2 +- docs/public/logo.svg | 40 ++--- docs/tut/advanced/nested-scoping.md | 6 +- docs/tut/crash-course/10-cleanup.md | 2 +- docs/tut/crash-course/11-control-flow.md | 4 +- docs/tut/crash-course/12-actions.md | 6 +- docs/tut/crash-course/13-strict-mode.md | 2 +- docs/tut/crash-course/14-concepts.md | 2 +- docs/tut/crash-course/2-creation.md | 2 +- docs/tut/crash-course/3-components.md | 4 +- docs/tut/crash-course/4-source.md | 6 +- docs/tut/crash-course/5-effect.md | 4 +- docs/tut/crash-course/6-scope.md | 4 +- docs/tut/crash-course/7-stateful-component.md | 4 +- docs/tut/crash-course/8-implicit-effect.md | 4 +- docs/tut/crash-course/9-derived-source.md | 6 +- 29 files changed, 255 insertions(+), 184 deletions(-) create mode 100644 docs/.vitepress/theme/home.css diff --git a/README.md b/README.md index 8ce077b..13e41b6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ for a quick introduction to the library. ## Code sample -```lua +```luau local create = vide.create local source = vide.source diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7708fdf..cde7870 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,70 +1,85 @@ -//import { defineConfig } from "vitepress" -import { withMermaid } from "vitepress-plugin-mermaid"; - -// https://vitepress.dev/reference/site-config -export default withMermaid({ - title: "Vide", - titleTemplate: ":title - A reactive UI library for Luau", - description: "A reactive UI library for Luau.", - base: "/vide/", - head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], - - themeConfig: { - logo: "/logo.svg", - - // https://vitepress.dev/reference/default-theme-config - nav: [ - { text: "Home", link: "/" }, - { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, - { text: "API", link: "/api/reactivity-core"}, - ], - - sidebar: { - "/api/": [ - { - text: "API", - items: [ - { text: "Reactivity: Core", link: "/api/reactivity-core" }, - { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, - { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, - { text: "Element Creation", link: "/api/creation" }, - { text: "Animation", link: "/api/animation" }, - { text: "Strict Mode", link: "/api/strict-mode" }, - ] - } - ], - - "/tut/": [ - { - text: "Crash Course", - items: [ - { text: "Introduction", link: "/tut/crash-course/1-introduction" }, - { text: "Element Creation", link: "/tut/crash-course/2-creation" }, - { text: "Components", link: "/tut/crash-course/3-components" }, - { text: "Sources", link: "/tut/crash-course/4-source" }, - { text: "Effects", link: "/tut/crash-course/5-effect" }, - { text: "Scopes", link: "/tut/crash-course/6-scope" }, - { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, - { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, - { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, - { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, - { text: "Actions", link: "/tut/crash-course/12-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, - { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } - ] - }, - { - text: "Advanced Reactivity", - items: [ - { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} - ] - } - ], - }, - - socialLinks: [ - { icon: "github", link: "https://github.com/centau/vide" } - ] - } -}) +//import { defineConfig } from "vitepress" +import { withMermaid } from "vitepress-plugin-mermaid"; +import { tabsMarkdownPlugin } from "vitepress-plugin-tabs" + +// https://vitepress.dev/reference/site-config +export default withMermaid({ + title: "Vide", + titleTemplate: ":title - A reactive UI library for Luau", + description: "A reactive UI library for Luau.", + base: "/vide/", + head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], + + markdown: { + config(md) { + md.use(tabsMarkdownPlugin) + } + }, + + themeConfig: { + logo: "/logo.svg", + + search: { + provider: "local" + }, + + footer: { + message: 'Released under the MIT License.', + }, + + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: "Home", link: "/" }, + { text: "Tutorials", link: "/tut/crash-course/1-introduction" }, + { text: "API", link: "/api/reactivity-core"}, + ], + + sidebar: { + "/api/": [ + { + text: "API", + items: [ + { text: "Reactivity: Core", link: "/api/reactivity-core" }, + { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, + { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, + { text: "Element Creation", link: "/api/creation" }, + { text: "Animation", link: "/api/animation" }, + { text: "Strict Mode", link: "/api/strict-mode" }, + ] + } + ], + + "/tut/": [ + { + text: "Crash Course", + items: [ + { text: "Introduction", link: "/tut/crash-course/1-introduction" }, + { text: "Element Creation", link: "/tut/crash-course/2-creation" }, + { text: "Components", link: "/tut/crash-course/3-components" }, + { text: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Scopes", link: "/tut/crash-course/6-scope" }, + { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, + { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, + { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, + { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, + { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, + { text: "Actions", link: "/tut/crash-course/12-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } + ] + }, + { + text: "Advanced Reactivity", + items: [ + { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} + ] + } + ], + }, + + socialLinks: [ + { icon: "github", link: "https://github.com/centau/vide" } + ] + } +}) diff --git a/docs/.vitepress/theme/home.css b/docs/.vitepress/theme/home.css new file mode 100644 index 0000000..c5f042c --- /dev/null +++ b/docs/.vitepress/theme/home.css @@ -0,0 +1,19 @@ +.home > * > .VPNavBar:not(.top) { + backdrop-filter: blur(0.5rem); + background-color: transparent !important; +} + +.home > * > .VPNavBar > .divider { + opacity: 0; +} + +.home > .VPContent { + display: flex; + justify-content: center; + flex-direction: column; +} + +.VPHome { + margin-top: auto !important; + margin-bottom: auto !important; +} \ No newline at end of file diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js index b8b9aa6..7f7e9a7 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -1,4 +1,8 @@ -// .vitepress/theme/index.js -import DefaultTheme from 'vitepress/theme' -import './vars.css' -export default DefaultTheme +// .vitepress/theme/index.js +import DefaultTheme from 'vitepress/theme' +import './vars.css' +import './home.css' + +export default { + extends: DefaultTheme, +} \ No newline at end of file diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css index 64df54f..6556617 100644 --- a/docs/.vitepress/theme/vars.css +++ b/docs/.vitepress/theme/vars.css @@ -1,3 +1,46 @@ -:root { - --vp-c-brand-1: #3086ff; -} +/* Colors */ + +:root { + --vp-c-brand-1: #3086ff; + --vp-c-brand-2: #75aeff; + + --vp-button-brand-bg: #3661a2; + --vp-button-brand-hover-bg: #24447f; + --vp-button-brand-press-bg: #4896f3; + + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient( + 120deg, + #3661a2, + #4896f3 + ); + --vp-home-hero-logo-background: -webkit-linear-gradient( + 120deg, + #3661a2, + #4896f3 + ); + + --vp-home-hero-image-filter: blur(96px); + + --vp-c-bg: #f2f5f8; + --vp-c-bg-alt: #dfe8f5; + --vp-c-bg-elv: #dde7f4; + --vp-c-bg-soft: #e8f1fe; + + --vp-c-border: #c0c3c6; + --vp-c-divider: #dfe2e6; + --vp-c-gutter: #dfe2e6; + --vp-plugin-tabs-tab-bg: var(--vp-c-bg); +} + +.dark { + --vp-c-brand-2: #234782; + --vp-c-bg: #0d131b; + --vp-c-bg-alt: #111720; + --vp-c-bg-elv: #182231; + --vp-c-bg-soft: #182231; + + --vp-c-border: #111720; + --vp-c-divider: #1d273c; + --vp-c-gutter: #181d27; +} \ No newline at end of file diff --git a/docs/api/animation.md b/docs/api/animation.md index 6481cc3..9f3b805 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -6,7 +6,7 @@ Returns a new source with a value always moving torwards the input source value. - **Type** - ```lua + ```luau function spring( source: () -> T & Animatable, period: number = 1, diff --git a/docs/api/creation.md b/docs/api/creation.md index 539967d..528b4fb 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -9,7 +9,7 @@ target instance. - **Type** - ```lua + ```luau function mount(component: () -> T, target: Instance?): () -> () ``` @@ -25,7 +25,7 @@ target instance. - **Example** - ```lua + ```luau local function App() return create "ScreenGui" { create "TextLabel" { Text = "Vide" } @@ -41,7 +41,7 @@ Creates a new UI element, applying any given properties. - **Type** - ```lua + ```luau function create(class: string): (Properties) -> Instance function create(instance: Instance): (Properties) -> Instance @@ -76,7 +76,7 @@ Creates a new UI element, applying any given properties. Basic element creation. - ```lua + ```luau local frame = create "Frame" { Name = "NewFrame", Position = UDim2.fromScale(1, 0) @@ -85,7 +85,7 @@ Creates a new UI element, applying any given properties. A component using property nesting. - ```lua + ```luau type Layout = { Layout = { Position: UDim2?, @@ -116,7 +116,7 @@ instances. - **Type** - ```lua + ```luau function action((Instance) -> (), priority: number = 1): Action ``` @@ -133,7 +133,7 @@ instances. An action to listen to changed properties: - ```lua + ```luau local function changed(property: string, callback: (new) -> ()) return action(function(instance) local con - instance:GetPropertyChangedSignal(property):Connect(function() @@ -161,7 +161,7 @@ A wrapper for `action()` to listen for property changes. - **Type** - ```lua + ```luau function changed(property: string, callback: (...unknown) -> ()): Action ``` diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 861e0e1..b68911c 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -13,7 +13,7 @@ Creates and runs a function in a new stable scope. - **Type** - ```lua + ```luau function root(fn: (() -> ()) -> T...): (() -> (), T...) ``` @@ -30,7 +30,7 @@ Creates a new source with the given value. - **Type** - ```lua + ```luau function source(value: T): Source type Source = @@ -45,7 +45,7 @@ Creates a new source with the given value. - **Example** - ```lua + ```luau local count = source(0) count() -- 0 @@ -59,7 +59,7 @@ Runs a side-effect in a new reactive scope on source update. - **Type** - ```lua + ```luau function effect(callback: () -> ()) ``` @@ -72,7 +72,7 @@ Runs a side-effect in a new reactive scope on source update. - **Example** - ```lua + ```luau local num = source(1) effect(function() @@ -92,7 +92,7 @@ Derives a new source in a new reactive scope from existing sources. - **Type** - ```lua + ```luau function derive(source: () -> T): () -> T ``` @@ -108,7 +108,7 @@ Derives a new source in a new reactive scope from existing sources. - **Example** - ```lua + ```luau local count = source(0) local text = derive(function() return `count: {count()}` end) diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md index 6870367..03aea97 100644 --- a/docs/api/reactivity-flow.md +++ b/docs/api/reactivity-flow.md @@ -8,7 +8,7 @@ Shows one of two components depending on an input source. - **Type** - ```lua + ```luau function show(source: () -> unknown, component: () -> T): () -> T? function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U ``` @@ -32,7 +32,7 @@ Shows one of a set of components depending on an input source and a mapping tabl - **Type** - ```lua + ```luau function switch(source: () -> K): (map: Map V>) -> V? ``` @@ -49,7 +49,7 @@ Shows one of a set of components depending on an input source and a mapping tabl - **Example** - ```lua + ```luau local logged = source(false) local button = switch(logged) { @@ -69,7 +69,7 @@ Maps each index in a table source to an object. - **Type** - ```lua + ```luau function indexes( source: () -> Map, transform: (value: () -> VI, index: KI) -> VO @@ -102,7 +102,7 @@ Maps each index in a table source to an object. The intended purpose of this function is to map each index in a table to a UI element. - ```lua + ```luau type Item = { name: string, icon: number @@ -129,7 +129,7 @@ Maps each value in a table source to an object. - **Type** - ```lua + ```luau function values( source: () -> Map, transform: (value: VI, index: () -> KI) -> VO @@ -169,7 +169,7 @@ Maps each value in a table source to an object. The intended purpose of this function is to map each value in a table to a UI element. - ```lua + ```luau type Item = { name: string, icon: number diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 824fe35..6f719e1 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -6,7 +6,7 @@ Runs a callback anytime a scope is reran or destroyed. - **Type** - ```lua + ```luau function cleanup(callback: () -> ()) function cleanup(obj: Destroyable) function cleanup(obj: Disconnectable) @@ -17,7 +17,7 @@ Runs a callback anytime a scope is reran or destroyed. - **Example** - ```lua + ```luau local data = source(1) effect(function() @@ -35,7 +35,7 @@ Runs a given function in a new stable scope. - **Type** - ```lua + ```luau function untrack(source: () -> T): T ``` @@ -46,7 +46,7 @@ Runs a given function in a new stable scope. - **Example** - ```lua + ```luau local a = source(0) local b = source(0) @@ -68,7 +68,7 @@ read can still be tracked inside a reactive scope. - **Type** - ```lua + ```luau function read(value: T | () -> T): T ``` @@ -79,7 +79,7 @@ trigger effects until after the function finishes running. - **Type** - ```lua + ```luau function batch(fn: () -> ()) ``` @@ -96,7 +96,7 @@ Creates a new context. - **Type** - ```lua + ```luau function context(default: T): Context type Context = @@ -113,7 +113,7 @@ Creates a new context. - **Example** - ```lua + ```luau local theme = context() local function Button() diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 570108b..b3cae5b 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -2,7 +2,7 @@ Strict mode is library-wide and can get set by doing: -```lua +```luau vide.strict = true ``` diff --git a/docs/index.md b/docs/index.md index 6fe3a4a..21f0227 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,22 @@ --- # https://vitepress.dev/reference/default-theme-home-page layout: home +pageClass: home +next: + text: 'Introduction' + link: '/tut/crash-course/1-introduction' hero: - name: Vide - text: "" + name: "Vide" tagline: A reactive UI library for Luau. image: src: /logo.svg - alt: Vide actions: - theme: brand - text: Tutorials + text: Crash Course link: /tut/crash-course/1-introduction - theme: alt text: API Reference link: /api/reactivity-core -features: - - title: In Development - details: Not recommended for production use. --- - diff --git a/docs/package.json b/docs/package.json index 5dce993..5e3035b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,14 +1,12 @@ -{ - "type": "module", - - "scripts": { - "docs:dev": "vitepress dev", - "docs:build": "vitepress build", - "docs:preview": "vitepress preview" - }, - - "devDependencies": { - "vitepress": "1.0.0-rc.25", - "vitepress-plugin-mermaid": "2.0.14" - } -} +{ + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "1.4.1", + "vitepress-plugin-mermaid": "2.0.17" + } +} diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg index 85488e0..1962800 100644 --- a/docs/public/full_logo.svg +++ b/docs/public/full_logo.svg @@ -1,4 +1,4 @@ - + diff --git a/docs/public/logo.svg b/docs/public/logo.svg index 92b06c1..22d840a 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,37 +1,31 @@ - - - - - + + + + + - + + - - - + + + - + - - - + + + - - - - - - - - + - + - + diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md index c5e6b4b..0542ccc 100644 --- a/docs/tut/advanced/nested-scoping.md +++ b/docs/tut/advanced/nested-scoping.md @@ -6,7 +6,7 @@ most common cases, but they do not cover all of them. This tutorial will demonstrate how to implement a `show()` control flow function using just sources and effects. -```lua +```luau local mount = vide.mount local source = vide.source local show = vide.show @@ -61,7 +61,7 @@ effect's reactive scope is destroyed whenever the show effect is rerun. The same can be achieved without the use of `show()`: -```lua +```luau local mount = vide.mount local source = vide.source local effect = vide.effect @@ -126,7 +126,7 @@ may be rerun needlessly and recreate the counter. Alternatively, instead of using `root()`: -```lua +```luau local mount = vide.mount local source = vide.source local effect = vide.effect diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index f6ca5f3..e498c23 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -5,7 +5,7 @@ a side-effect from a source update. Vide provides a function `cleanup()` which is used to queue a callback for the next time a reactive scope is rerun or destroyed, or when a stable scope is destroyed. -```lua +```luau local root = vide.root local source = vide.source local effect = vide.effect diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md index b206915..298ce93 100644 --- a/docs/tut/crash-course/11-control-flow.md +++ b/docs/tut/crash-course/11-control-flow.md @@ -14,7 +14,7 @@ instance. update to display the current value at that index. Each table index is given a single corresponding UI element. -```lua +```luau local list = source { "finish the crash course", "star Vide's GitHub" @@ -90,7 +90,7 @@ end When you edit a table in a source, you must set that table again to actually update the source. -```lua +```luau local src = source { 1, 2 } local data = src() table.insert(data, 3) -- no effects will run diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index 176c12e..64d3fce 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -3,11 +3,11 @@ Actions in Vide are special callbacks that you can pass along with properties, to run some code on an instance receiving them. -```lua +```luau local action = vide.action ``` -```lua +```luau create "TextLabel" { Text = "test", @@ -22,7 +22,7 @@ create "TextLabel" { Actions can be wrapped with functions for reuse. Below is an example of an action used to listen for property changes: -```lua +```luau local action = vide.action local effect = vide.effect local cleanup = vide.cleanup diff --git a/docs/tut/crash-course/13-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md index 6efa1c3..f2b30fe 100644 --- a/docs/tut/crash-course/13-strict-mode.md +++ b/docs/tut/crash-course/13-strict-mode.md @@ -13,7 +13,7 @@ Strict mode will run derived sources and effects twice each time they update. This is to help ensure that derived source computations are pure, and that any cleanups made in derived sources or effects are done properly. -```lua +```luau local source = vide.source local effect = vide.effect diff --git a/docs/tut/crash-course/14-concepts.md b/docs/tut/crash-course/14-concepts.md index 24ca703..77e9e7f 100644 --- a/docs/tut/crash-course/14-concepts.md +++ b/docs/tut/crash-course/14-concepts.md @@ -73,7 +73,7 @@ relationships between effects and the sources they depend on. ### Code -```lua +```luau local count = source(0) root(function() diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 5e76173..668a470 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -5,7 +5,7 @@ Instances are created using `create()`. Parentheses `()` can be omitted when calling functions with string or table literals for brevity. -```lua +```luau local create = vide.create return create "ScreenGui" { diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 8e5890f..0b803ca 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -10,7 +10,7 @@ together. ::: code-group -```lua [Button.luau] +```luau [Button.luau] local create = vide.create local function Button(props: { @@ -34,7 +34,7 @@ end return Button ``` -```lua [Menu.luau] +```luau [Menu.luau] local create = vide.create local Button = require(Button) diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index 08da45c..d862156 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -5,7 +5,7 @@ Vide's reactivity. A source can be created using `source()`. -```lua +```luau local source = vide.source local count = source(0) @@ -16,13 +16,13 @@ The value passed to `source()` is the initial value of the source. The value of a source can be set by calling it with an argument, and can be read by calling it with no arguments. -```lua +```luau count(count() + 1) -- increment count by 1 ``` Sources can be *derived* by wrapping them in functions. -```lua +```luau local count = source(0) local text = function() diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 5e6043c..29e6358 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -5,7 +5,7 @@ A source and effect is analogous to a signal and connection. Effects are created using `effect()`. -```lua +```luau local source = vide.source local effect = vide.effect @@ -26,7 +26,7 @@ that source is updated. Derived sources are also tracked, it doesn't matter how deeply nested inside a function a source is. -```lua +```luau local source = vide.source local effect = vide.effect diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index 2853fff..6c12088 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -22,7 +22,7 @@ destroyed, and so on. This is why all scopes must be created within another scope, except `root()` which is used to create the initial scope that you can manually destroy. -```lua +```luau local root = vide.root local source = vide.source local effect = vide.effect @@ -45,7 +45,7 @@ count(1) -- prints "1" The scope created by `root()` can be destroyed. -```lua +```luau local function setup() local count = source(0) diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-stateful-component.md index 493f9bc..5126c73 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-stateful-component.md @@ -5,7 +5,7 @@ store the data, and effects to display the data. ## Internal State -```lua +```luau local create = vide.create local source = vide.source local effect = vide.effect @@ -37,7 +37,7 @@ count source is created inside the component. External sources can also be passed into components for them to use. -```lua +```luau local function Counter(props: { count: () -> number }) local count = props.count diff --git a/docs/tut/crash-course/8-implicit-effect.md b/docs/tut/crash-course/8-implicit-effect.md index bbd92d4..cdb75fb 100644 --- a/docs/tut/crash-course/8-implicit-effect.md +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -3,7 +3,7 @@ Explicitly creating effects to update properties is tedious. You can *implicitly* create an effect to update properties instead. -```lua +```luau local create = vide.create local source = vide.source @@ -34,7 +34,7 @@ with a number key instead of string key) can return an instance or an array of instances. An effect is automatically created to unparent removed instances and parent new instances on source update. -```lua +```luau local items = source { create "TextLabel" { Text = "A" } } diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 12a89b2..19a6a5d 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -2,7 +2,7 @@ We have seen the basic way to derive a source: -```lua +```luau local count = source(0) local text = function() @@ -18,7 +18,7 @@ However, in some cases where this source could be used by multiple effects at the same time, the function wrapping the source will needlessly rerun to convert the count into a string for each effect using it. -```lua +```luau local source = vide.source local effect = vide.effect @@ -39,7 +39,7 @@ To avoid this, you can use `derive()` to derive a new source instead. This will run a function in a reactive scope only when a source used inside updated. Reading this derived source multiple times will just return a cached result. -```lua +```luau local source = vide.source local effect = vide.effect local derive = vide.derive From 1edcd6516638b536bbd6d969f08f70a39f9db2e9 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:37:41 +0000 Subject: [PATCH 39/94] Update docs --- docs/.vitepress/config.ts | 7 ------- docs/index.md | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cde7870..1b30a51 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,6 +1,5 @@ //import { defineConfig } from "vitepress" import { withMermaid } from "vitepress-plugin-mermaid"; -import { tabsMarkdownPlugin } from "vitepress-plugin-tabs" // https://vitepress.dev/reference/site-config export default withMermaid({ @@ -9,12 +8,6 @@ export default withMermaid({ description: "A reactive UI library for Luau.", base: "/vide/", head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], - - markdown: { - config(md) { - md.use(tabsMarkdownPlugin) - } - }, themeConfig: { logo: "/logo.svg", diff --git a/docs/index.md b/docs/index.md index 21f0227..295d137 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ hero: src: /logo.svg actions: - theme: brand - text: Crash Course + text: Tutorials link: /tut/crash-course/1-introduction - theme: alt text: API Reference From 94add5d452ff3baaa0d696dda9df7b89f655ba63 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:46:45 +0000 Subject: [PATCH 40/94] Update vitepress version --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 5e3035b..a329007 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docs:preview": "vitepress preview" }, "devDependencies": { - "vitepress": "1.4.1", + "vitepress": "^1.4.5", "vitepress-plugin-mermaid": "2.0.17" } } From a3cc2dfbdaa1e19fea1e363f7be01a32423678b9 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Mon, 4 Nov 2024 23:18:04 +0000 Subject: [PATCH 41/94] Update docs --- docs/.vitepress/config.ts | 15 +- docs/.vitepress/theme/{home.css => index.css} | 8 +- docs/.vitepress/theme/index.js | 4 +- docs/.vitepress/theme/vars.css | 8 +- docs/api/animation.md | 11 +- docs/api/creation.md | 142 +++++------- docs/api/reactivity-core.md | 109 ++++++--- docs/api/reactivity-dynamic.md | 212 +++++++++++++++++ docs/api/reactivity-flow.md | 218 ------------------ docs/api/reactivity-utility.md | 78 ++++--- docs/api/strict-mode.md | 22 +- docs/tut/advanced/nested-scoping.md | 192 --------------- docs/tut/control-flow/1-intro.md | 0 docs/tut/control-flow/2-show.md | 1 - docs/tut/control-flow/3-switch.md | 1 - docs/tut/control-flow/4-indexes.md | 1 - docs/tut/control-flow/5-values.md | 1 - docs/tut/crash-course/1-introduction.md | 16 +- docs/tut/crash-course/10-cleanup.md | 2 +- docs/tut/crash-course/11-control-flow.md | 98 -------- docs/tut/crash-course/11-dynamic-scope.md | 158 +++++++++++++ docs/tut/crash-course/12-actions.md | 7 +- docs/tut/crash-course/13-strict-mode.md | 1 + docs/tut/crash-course/14-concepts.md | 34 +-- docs/tut/crash-course/5-effect.md | 26 ++- docs/tut/crash-course/6-scope.md | 58 ++--- ...l-component.md => 7-reactive-component.md} | 8 +- docs/tut/crash-course/8-implicit-effect.md | 32 ++- docs/tut/crash-course/9-derived-source.md | 13 +- docs/tut/dynamic-scoping/custom.md | 144 ++++++++++++ 30 files changed, 850 insertions(+), 770 deletions(-) rename docs/.vitepress/theme/{home.css => index.css} (83%) create mode 100644 docs/api/reactivity-dynamic.md delete mode 100644 docs/api/reactivity-flow.md delete mode 100644 docs/tut/advanced/nested-scoping.md delete mode 100644 docs/tut/control-flow/1-intro.md delete mode 100644 docs/tut/control-flow/2-show.md delete mode 100644 docs/tut/control-flow/3-switch.md delete mode 100644 docs/tut/control-flow/4-indexes.md delete mode 100644 docs/tut/control-flow/5-values.md delete mode 100644 docs/tut/crash-course/11-control-flow.md create mode 100644 docs/tut/crash-course/11-dynamic-scope.md rename docs/tut/crash-course/{7-stateful-component.md => 7-reactive-component.md} (91%) create mode 100644 docs/tut/dynamic-scoping/custom.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1b30a51..380542a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -34,7 +34,7 @@ export default withMermaid({ items: [ { text: "Reactivity: Core", link: "/api/reactivity-core" }, { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, - { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, + { text: "Reactivity: Dynamic Scoping", link: "/api/reactivity-dynamic" }, { text: "Element Creation", link: "/api/creation" }, { text: "Animation", link: "/api/animation" }, { text: "Strict Mode", link: "/api/strict-mode" }, @@ -52,20 +52,25 @@ export default withMermaid({ { text: "Sources", link: "/tut/crash-course/4-source" }, { text: "Effects", link: "/tut/crash-course/5-effect" }, { text: "Scopes", link: "/tut/crash-course/6-scope" }, - { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, + { text: "Reactive Components", link: "/tut/crash-course/7-reactive-component" }, { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, + { text: "Dynamic Scoping", link: "/tut/crash-course/11-dynamic-scope" }, { text: "Actions", link: "/tut/crash-course/12-actions" }, { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } ] }, { - text: "Advanced Reactivity", + text: "Dynamic Scoping", + items: [ + { text: "Custom Scopes", link: "/tut/dynamic-scoping/custom"} + ] + }, + { + text: "Design Patterns", items: [ - { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} ] } ], diff --git a/docs/.vitepress/theme/home.css b/docs/.vitepress/theme/index.css similarity index 83% rename from docs/.vitepress/theme/home.css rename to docs/.vitepress/theme/index.css index c5f042c..9c3ca9a 100644 --- a/docs/.vitepress/theme/home.css +++ b/docs/.vitepress/theme/index.css @@ -16,4 +16,10 @@ .VPHome { margin-top: auto !important; margin-bottom: auto !important; -} \ No newline at end of file +} + +.VPBadge a { + text-decoration: none; + color: inherit + } + \ No newline at end of file diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js index 7f7e9a7..54a4c62 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -1,8 +1,8 @@ // .vitepress/theme/index.js import DefaultTheme from 'vitepress/theme' import './vars.css' -import './home.css' +import './index.css' export default { extends: DefaultTheme, -} \ No newline at end of file +} diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css index 6556617..7fbf3b5 100644 --- a/docs/.vitepress/theme/vars.css +++ b/docs/.vitepress/theme/vars.css @@ -31,6 +31,11 @@ --vp-c-divider: #dfe2e6; --vp-c-gutter: #dfe2e6; --vp-plugin-tabs-tab-bg: var(--vp-c-bg); + + --vp-badge-info-bg: #122d26; + --vp-badge-info-text: #6bdbbd; + --vp-badge-tip-bg: #132741; + --vp-badge-tip-text: #70abfa; } .dark { @@ -43,4 +48,5 @@ --vp-c-border: #111720; --vp-c-divider: #1d273c; --vp-c-gutter: #181d27; -} \ No newline at end of file +} + diff --git a/docs/api/animation.md b/docs/api/animation.md index 9f3b805..8b84ce9 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -1,6 +1,6 @@ -# Animation API +# Animation -## spring() +## spring() REACTIVE Returns a new source with a value always moving torwards the input source value. @@ -18,8 +18,7 @@ Returns a new source with a value always moving torwards the input source value. - **Details** - An effect is created to update the new source every frame based on the input - source value. + Creates a reactive scope internally to detect source updates. The movement is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). @@ -39,3 +38,7 @@ Returns a new source with a value always moving torwards the input source value. You can change when the solver runs by calling `vide.step(dt)`, which will advance the simulation time by `dt` seconds and automatically stop the solver running in heartbeat. + + ::: warning + Large periods or damping ratios can break the spring. + ::: diff --git a/docs/api/creation.md b/docs/api/creation.md index 528b4fb..578b10f 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -1,39 +1,4 @@ -# Element Creation API - -
- -## mount() - -Runs a function in a new stable scope and optionally applies its result to a -target instance. - -- **Type** - - ```luau - function mount(component: () -> T, target: Instance?): () -> () - ``` - -- **Details** - - The result of the function is applied to a target in the same way - properties are using `create()`. - - The function is ran in a new stable scope, just like - [root()](reactivity-core.md#root). - - Returns a function that when called will destroy the stable scope. - -- **Example** - - ```luau - local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "Vide" } - } - end - - mount(App, game.StarterGui) - ``` +# Element Creation ## create() @@ -45,7 +10,7 @@ Creates a new UI element, applying any given properties. function create(class: string): (Properties) -> Instance function create(instance: Instance): (Properties) -> Instance - type Properties = Map + type Properties = Map ``` - **Details** @@ -77,42 +42,22 @@ Creates a new UI element, applying any given properties. Basic element creation. ```luau - local frame = create "Frame" { - Name = "NewFrame", - Position = UDim2.fromScale(1, 0) + local frame = create "TextButton" { + Name = "Button", + Size = UDim2.fromOffset(200, 160), + + Activated = function() + print "clicked" + end, + + create "UICorner" {} } ``` - A component using property nesting. - - ```luau - type Layout = { - Layout = { - Position: UDim2?, - Size: UDim2?, - AnchorPoint: Vector2? - } - } - - type Children = { - Children = Array - } - - function Background(props: Layout & Children & { - Color: Color3 - }) - return create "Frame" { - BackgroundColor3 = props.Color, - props.Layout, - props.Children - } - end - ``` - ## action() -Creates a callback that can be passed to `create()` to invoke custom actions on -instances. +Creates a special object that can be passed to `create()` to invoke custom +actions on instances. - **Type** @@ -122,27 +67,27 @@ instances. - **Details** - When passed to `create()`, the given callback is called with the instance - being created as the only argument. Actions take precedence over property - and child assignments. + When passed to `create()`, the function is called with the instance being + created as the only argument. Actions take precedence over property and + child assignments. A priority can be optionally specified to ensure certain actions run after - other actions. Higher priority numbers are ran after lower priority numbers. + other actions. Lower priority values are ran first. - **Example** An action to listen to changed properties: ```luau - local function changed(property: string, callback: (new) -> ()) + local function changed(property: string, fn: (new) -> ()) return action(function(instance) - local con - instance:GetPropertyChangedSignal(property):Connect(function() - callback(instance[property]) + local cn = instance:GetPropertyChangedSignal(property):Connect(function() + fn(instance[property]) end) - -- disconnect on reactive scope destruction to allow gc of instance + -- disconnect on scope destruction to allow gc of instance cleanup(function() - con:Disconnect() + cn:Disconnect() end) end) end @@ -150,7 +95,7 @@ instances. local output = source "" create "TextBox" { - -- will update the `output` source anytime the text property is changed + -- will update the output source anytime the text property is changed changed("Text", output) } ``` @@ -162,15 +107,46 @@ A wrapper for `action()` to listen for property changes. - **Type** ```luau - function changed(property: string, callback: (...unknown) -> ()): Action + function changed(property: string, fn: (unknown) -> ()): Action ``` - **Details** - Will run the given callback any time the property is changed, as well as - when the action is initially run. + Will run the given function immediately and whenever the property updates. - The changed connection is disconnected when the scope the action is ran in - is destroyed. + The function is called with the updated property value. Runs with an action priority of 1. + +## mount() STABLE + +Runs a function in a new stable scope and optionally applies its result to a +target instance. + +- **Type** + + ```luau + function mount(component: () -> T, target: Instance?): () -> () + ``` + +- **Details** + + This is a utility for `root()` when parenting a component to an existing + instance. + + The result of the function is applied to a target in the same way + properties are using `create()`. + + Returns a function that when called will destroy the stable scope. + +- **Example** + + ```luau + local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "Vide" } + } + end + + local destroy = mount(App, game.StarterGui) + ``` diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index b68911c..ebbc5dd 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -1,32 +1,40 @@ -# Reactivity API: Core +# Reactivity: Core -
+## Scopes + +Vide code can run in one of two scopes: STABLE or REACTIVE. + +- Reactive scopes rerun if a source read within updates. +- Stable scopes never rerun. +- Reactive scopes cannot be created directly within another reactive scope. +- When a scope is destroyed, all scopes created within are also destroyed. + +Different functions in Vide's API will run code in different scopes. :::warning Yielding is not allowed in any stable or reactive scope. Strict mode will check for this. ::: -## root() +## root() STABLE -Creates and runs a function in a new stable scope. +Runs a function in a new stable scope. - **Type** ```luau - function root(fn: (() -> ()) -> T...): (() -> (), T...) + function root(fn: (Destructor) -> T...): (Destructor, T...) + + type Destructor = () -> () ``` - **Details** - Returns a function to destroy the root scope. Also passes this function as - the first argument into its callback. - - All values returned by the callback are also returned following the destructor. + Returns a destructor and any values returned by the callback. ## source() -Creates a new source with the given value. +Creates a new source. - **Type** @@ -40,71 +48,64 @@ Creates a new source with the given value. - **Details** - Calling the returned source with no argument will return its stored value, - calling with an argument will set a new value. + Call the returned source with no argument to read its value. + Call the returned source with an argument to set its value. - **Example** ```luau local count = source(0) - - count() -- 0 - - count(count() + 1) -- 1 + print(count())-- 0 + count(count() + 1) + print(count()) -- 1 ``` -## effect() +## effect() REACTIVE -Runs a side-effect in a new reactive scope on source update. +Runs a function in a new reactive scope. - **Type** ```luau - function effect(callback: () -> ()) + function effect(fn: () -> ()) ``` - **Details** - Any time a source referenced in the callback is updated, the callback will - be reran. - - The callback is ran once immediately. + The function is ran once immediately. - **Example** ```luau - local num = source(1) + local count = source(1) effect(function() - print(num()) + print(count()) end) -- prints 1 - num(num() + 1) + count(2) -- prints 2 ``` -## derive() +## derive() REACTIVE -Derives a new source in a new reactive scope from existing sources. +Runs a function in a new reactive scope to compute a value for new source. - **Type** ```luau - function derive(source: () -> T): () -> T + function derive(fn: () -> T): () -> T ``` - **Details** - The derived source will have its value recalculated when any source source - it derives from is updated. + Anytime the reactive scope reruns, the output source value is set to what is + returned. - Anytime its value is recalculated it is also cached, subsequent calls will - retun this cached value until it recalculates again. - - The callback is ran once immediately. + The function is ran once immediately. - **Example** @@ -112,11 +113,43 @@ Derives a new source in a new reactive scope from existing sources. local count = source(0) local text = derive(function() return `count: {count()}` end) - text() -- "count: 0" + print(text()) -- "count: 0" count(1) - text() -- "count: 1" + print(text()) -- "count: 1" ``` --------------------------------------------------------------------------------- + A `derive()` should be used instead of a pure function when you expect it to + be read multiple times between updates, because `derive()` will cache the + result to prevent recomputing it on every read. + + ::: code-group + + ```luau [Pure Function] + local count = source(0) + + local text = function() + print "ran" + return `count: {count()}` + end + + count(1) + print(text()) -- prints "ran" followed by "count: 1" + print(text()) -- prints "ran" followed by "count: 1" + ``` + + ```luau [Derived Source] + local count = source(0) + + local text = derive(function() -- [!code highlight] + print "ran" + return `count: {count()}` + end) -- [!code highlight] + + count(1) -- prints "ran" + print(text()) -- prints "count: 1" + print(text()) -- prints "count: 1" + ``` + + ::: diff --git a/docs/api/reactivity-dynamic.md b/docs/api/reactivity-dynamic.md new file mode 100644 index 0000000..a3e5168 --- /dev/null +++ b/docs/api/reactivity-dynamic.md @@ -0,0 +1,212 @@ +# Reactivity: Dynamic Scoping + +Dynamic scoping is the act of creating and destroying new scopes in response to +source updates. Vide provides functions for some common use-cases to do this. + +## show() REACTIVE + +Shows a component if the source is truthy. Optionally shows a fallback component +if the source is falsey. + +- **Type** + + ```luau + function show(source: () -> unknown, component: () -> T): () -> T? + function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + The component is run in a stable scope when truthy, otherwise the stable + scope is destroyed. + + Returns a source holding an instance of the currently shown component or + `nil` if no component is currently shown. + +## switch() REACTIVE + +Shows one of a set of components depending on a source and a mapping table. + +- **Type** + + ```luau + function switch(source: () -> K): (map: Map V>) () -> V? + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + When the source updates, its value is inputted into a map to get a component + constructor. This component is then run in a stable scope. The previous + stable scope is destroyed. + + Returns a source holding an instance of the currently shown component or + `nil` if no component is currently shown. + +- **Example** + + ```luau + local logged = source(false) + + local button = switch(logged) { + [true] = function() + return Button { Text = "Log out", Toggle = logged } + end, + + [false] = function() + return Button { Text = "Log in", Toggle = logged } + end + } + ``` + +## indexes() REACTIVE + +Shows a component for each index in a table. + +- **Type** + + ```luau + function indexes( + source: () -> Map, + transform: (value: () -> VI, index: KI) -> VO + ): Array + +- **Details** + + Creates a reactive scope internally to detect source updates. + + When the source table updates, a component is generated for each index in + the table. + + - For any added index, the `transform` function is run in a new stable + scope to produce an instance that is cached. + - For any removed index, the stable scope for that index is destroyed. + + The `transform` function is called with: + + 1. A *source containing the index's value*. + 2. The *index itself*. + + Anytime an existing index's value changes, the `transform` function is not + rerun, instead, that index's corresponding source is updated with the new + value. + + Returns a source holding an array of instances currently shown. + +- **Example** + + ```luau + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = indexes(items, function(item, i) + return ItemDisplay { + Name = function() + return i .. ": " .. item().name + end, + + Image = function() + return "rbxassetid://" .. item().icon + end, + } + end) + ``` + +## values() REACTIVE + +Shows a component for each value in a table. + +- **Type** + + ```luau + function values( + source: () -> Map, + transform: (value: VI, index: () -> KI) -> VO + ): Array + +- **Details** + + Operates with the same idea as `indexes()`, but applied to values instead of + indexes. + + Creates a reactive scope internally to detect source updates. + + When the source table updates, a component is generated for each value in + the table. + + - For any added value, the `transform` function is run in a new stable scope + to produce an instance that is cached. + - For any removed value, the stable scope for that value is destroyed. + + The `transform` function is called with: + + 1. The *value itself*. + 2. A *source containing the value's index*. + + Anytime an existing value's index changes, the `transform` function is not + rerun, instead, that value's corresponding source is updated with the new + index. + + Returns a source holding an array of instances currently shown. + + ::: warning + Having the same values appear multiple times in the input source table can + cause unexpected behavior. Strict mode has checks for this. + ::: + +- **Example** + + ```luau + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = values(items, function(item, i) + return ItemDisplay { + Name = function() + return i() .. ": " .. item.Name + end + + Image = "rbxassetid://" .. item.icon, + } + 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 values. It maps an index to a UI element. + + e.g. + - List of character or weapon stats. + + In most cases, both functions will produce the same observed result. + The main difference is performance, picking the right function to use can + result in less property updates and less re-renders. One case to note is + that `values()` works nicely when animating re-ordering of instances, since + the source index can be used to animate a change in position for the UI + element. + +-------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md deleted file mode 100644 index 03aea97..0000000 --- a/docs/api/reactivity-flow.md +++ /dev/null @@ -1,218 +0,0 @@ -# Reactivity API: Control Flow - -
- -## show() - -Shows one of two components depending on an input source. - -- **Type** - - ```luau - function show(source: () -> unknown, component: () -> T): () -> T? - function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U - ``` - -- **Details** - - Returns a source holding an instance of the currently shown component. - - When the input source changes from a falsey to a truthy value, the - component will be reran under a new stable scope. If it changes from a - truthy to falsey value, the stable scope the component was created in will - be destroyed, and the returned source will output `nil`, or a fallback - component if given. - - The fallback component is also ran under a new stable scope, and destroyed - when the input source switches back to truthy. - -## switch() - -Shows one of a set of components depending on an input source and a mapping table. - -- **Type** - - ```luau - function switch(source: () -> K): (map: Map V>) -> V? - ``` - -- **Details** - - Returns a source holding an instance of the currently shown component. - - When the input source changes, the new value will be used to lookup a given - mapping table to get a component, which will be ran under a new stable - scope. If the input source changes, the stable scope the component was - created in will be destroyed, and a new component created under a new - stable scope. If no component is found for an input value, the switch will - output `nil`. - -- **Example** - - ```luau - local logged = source(false) - - local button = switch(logged) { - [true] = function() - return Button { Text = "Log out", Toggle = logged } - end, - - [false] = function() - return Button { Text = "Log in", Toggle = logged } - end - } - ``` - -## indexes() - -Maps each index in a table source to an object. - -- **Type** - - ```luau - function indexes( - source: () -> Map, - transform: (value: () -> VI, index: KI) -> VO - ): Array - -- **Details** - - Returns a source holding an array of instances currently shown. - - When the input source changes, each *index* in the new table is compared with - the last input table. - - - For any new index, the `transform` function is ran under a new stable - scope to produce a new instance. - - For any removed index, the stable scope for that index is destroyed. - - Unchanged indexes are untouched. - - The transform function is called only ever *once* for each index in the - source table. - - 1. First argument is a *source containing the index's value*. - 2. Second argument is the *index itself*. - - Anytime an existing index's value changes, the transform function is not - rerun, instead the source value for that index will update, causing anything - depending on it to update too. - -- **Example** - - The intended purpose of this function is to map each index in a table to - a UI element. - - ```luau - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = indexes(items, function(item, i) - return ItemDisplay { - Name = function() - return i .. ": " .. item().name - end, - - Image = function() - return "rbxassetid://" .. item().icon - end, - } - end) - ``` - -## values() - -Maps each value in a table source to an object. - -- **Type** - - ```luau - function values( - source: () -> Map, - transform: (value: VI, index: () -> KI) -> VO - ): Array - -- **Details** - - Returns a source holding an array of instances currently shown. - - When the input source changes, each *value* in the new table is compared with - the last input table. Similar to `indexes()` but for values instead of indexes. - - - For any new value, the `transform` function is ran under a new stable - scope to produce a new instance. - - For any removed value, the stable scope for that value is destroyed. - - Unchanged values are untouched. - - The transform function is only ever called *once* for each value in the - source table. - - 1. First argument is the *value itself*. - 2. Second argument is a *source containing the value's index*. - - Anytime an existing value's index changes, the transform function is not - rerun, instead the source index for that value will update, causing anything - depending on it to update too. - - ::: warning - Having primitive values in the input source table can cause unexpected - behavior, as duplicate values can result in multiple tranforms being ran for - a single value, meaning there can be multiple source indexes bound to the - same UI element. Strict mode has checks for this. - ::: - -- **Example** - - The intended purpose of this function is to map each value in a table to - a UI element. - - ```luau - type Item = { - name: string, - icon: number - } - - local items = source {} :: () -> Array - - local displays = values(items, function(item, i) - return ItemDisplay { - Name = function() - return i() .. ": " .. item.Name - end - - Image = "rbxassetid://" .. item.icon, - } - 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 values. 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. One case to note is - that `values()` works nicely when animating re-ordering of instances, since - the value is not destroyed when indexes are changed, and the source index - can be used to animate a change in position for the UI element. - --------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 6f719e1..d084976 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -1,16 +1,15 @@ -# Reactivity API: Utility +# Reactivity: Utility ## cleanup() -Runs a callback anytime a scope is reran or destroyed. +Queues a callback to run when a scope is reran or destroyed. - **Type** ```luau - function cleanup(callback: () -> ()) - function cleanup(obj: Destroyable) - function cleanup(obj: Disconnectable) + function cleanup(v: Function | Disconnectable | Destroyable) + type Function = () -> () type Destroyable = { destroy: () -> () } type Disconnectable = { disconnect: () -> () } ``` @@ -18,20 +17,27 @@ Runs a callback anytime a scope is reran or destroyed. - **Example** ```luau - local data = source(1) + local count = source(0) - effect(function() - local label = create "TextLabel" { Text = data() } + local destroy = root(function() + effect(function() + count() - cleanup(function() - label:Destroy() + cleanup(function() + print "cleaned" + end) end) - end) + end + + -- nothing printed yet + count(1) -- prints "cleaned" + count(2) -- prints "cleaned" + destroy() -- prints "cleaned" ``` -## untrack() +## untrack() STABLE -Runs a given function in a new stable scope. +Runs a function in a new stable scope. - **Type** @@ -55,16 +61,15 @@ Runs a given function in a new stable scope. end) print(sum()) -- 0 - b(1) + b(1) -- untracked so reactive scope created by derive() does not rerun print(sum()) -- 0 - a(1) + a(1) -- reactive scope created by derive() reruns print(sum()) -- 2 ``` ## read() -Utility used to read a value that is either a primitive or a source. Sources -read can still be tracked inside a reactive scope. +Utility used to read a value that is either a primitive or a source. - **Type** @@ -74,8 +79,8 @@ read can still be tracked inside a reactive scope. ## batch() -Runs a given function where any source updates made within the function do not -trigger effects until after the function finishes running. +Runs a function where any source updates made within the function do not +trigger effects until after the function ends. - **Type** @@ -86,11 +91,29 @@ trigger effects until after the function finishes running. - **Details** Improves performance when an effect depends on multiple sources, and those - sources need to be updated. Updating those sources inside a batch call will - only cause the effect to run once after the batch call ends instead of after - each time a source is updated. + sources need to be updated. -## context() +- **Example** + + ```luau + local a = source(0) + local b = source(0) + + effect(function() + print(a() + b()) + end) + + -- prints "0" + + batch(function() + a(1) -- no print + b(2) -- no print + end) + + -- prints "3" + ``` + +## context() STABLE Creates a new context. @@ -101,15 +124,17 @@ Creates a new context. type Context = () -> T -- get - & (T, () -> ()) -> () -- set + & (T, () -> U) -> U -- set ``` - **Details** Calling `context()` returns a new context function. Call this function with no arguments to get the context value. - Call this function with a value and a callback to set a new context with the - given value. + Call this function with a value and a function to create a new context with + the given value. + + The new context is run under a stable scope. - **Example** @@ -131,4 +156,3 @@ Creates a new context. end) ``` --------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index b3cae5b..d3ce5cb 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -14,25 +14,23 @@ and identifying improper usage. Currently, strict mode will: -1. Run derived sources twice a source updates. -2. Run effects twice when a source updates. -3. Throw an error if yields occur where they are not allowed. -4. Checks for `indexes()` and `values()` returning primitive values. -5. Checks for `values()` input having duplicate values. -6. Checks for duplicate nested properties at same depth. -7. Better error reporting and stack traces + creation traces of property bindings. +1. Run reactive scopes twice when a source updates. +2. Throw an error if yields occur where they are not allowed. +3. Checks for `indexes()` and `values()` outputting primitive values. +4. Checks for `values()` input having duplicate values. +5. Checks for duplicate nested properties at same depth. +6. Better error reporting and stack traces + creation traces of property bindings. -By rerunning derived sources and effects twice each time they update, it helps -ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done correctly. +By rerunning reactive scopes twice each time they update, it helps ensure that +computations are pure, and that any cleanup is done correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, which strict mode will catch. As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly -when binding properties to sources. +when implicit effects are created for instance property updating. It is recommended to develop UI with strict mode and to disable it when pushing to -production. In Roblox, production code compiles at O2 by default, so you don't +production. In Roblox, production code compiles at O2 by default, so you do not need to worry about disabling strict mode unless you have manually enabled it. diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md deleted file mode 100644 index 0542ccc..0000000 --- a/docs/tut/advanced/nested-scoping.md +++ /dev/null @@ -1,192 +0,0 @@ -# Nested Scopes - -Nesting scopes gives you finer control over the reactive graph, but needs more work to do. The built-in control flow functions try to cover the -most common cases, but they do not cover all of them. - -This tutorial will demonstrate how to implement a `show()` control flow function -using just sources and effects. - -```luau -local mount = vide.mount -local source = vide.source -local show = vide.show - -local function Counter() - local count = source(0) - - return create "TextButton" { - Text = count, - Activated = function() count(count() + 1) end - } -end - -root(function() - local toggled = source(true) - - show(toggled, Button) -end) -``` - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph mount - direction LR - toggle --> show - - subgraph show[show effect] - text[Text effect] - end -end -``` - -Above is the reactive graph for `show()`. It creates a new effect depending on -`toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The -`show` effect calls `Counter`, which creates a new reactive scope to update its -text whenever `count` changes. As per the rules of reactive scopes, a reactive -scope rerunning will destroy any scopes created within it. So the text -effect's reactive scope is destroyed whenever the show effect is rerun. - -The same can be achieved without the use of `show()`: - -```luau -local mount = vide.mount -local source = vide.source -local effect = vide.effect -local cleanup = vide.cleanup - -local function Counter() - local count = source(0) - - return create "TextButton" { - Text = count, - Activated = function() count(count() + 1) end - } -end - -mount(function() - local toggled = source(true) - - effect(function() - if toggled() then - local destroy = root(function() - Counter() - end) - cleanup(destroy) - end - end) -end) -``` - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph mount - direction LR - toggle --> effect - - subgraph effect - subgraph mount2[inner mount] - text[Text effect] - end - end -end -``` - -This is another way to achieve the same. Here we use `root()` within the effect -to manually create and destroy a new stable scope whenever the effect reruns. - -The reason for creating a stable scope is to prevent the effect from tracking -any sources that may be read inside the `Counter()` call. Otherwise, the effect -may be rerun needlessly and recreate the counter. - -Alternatively, instead of using `root()`: - -```luau -local mount = vide.mount -local source = vide.source -local effect = vide.effect -local untrack = vide.untrack - -local function Counter() - local count = source(0) - - return create "TextButton" { - Text = count, - Activated = function() count(count() + 1) end - } -end - -mount(function() - local toggled = source(true) - - effect(function() - if toggled() then - untrack(Button) - end - end) -end) -``` - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph mount - direction LR - toggle --> effect - - subgraph effect - text[Text effect] - end -end -``` - -Without the use of `untrack()`, an error would occur, since Vide does not allow -the creation of reactive scopes inside reactive scopes. `untrack()` creates a -stable scope inside the reactive scope, and we can create another reactive scope -inside that stable scope. The -reason for this, is because if the `Counter` component reads from a source -internally, that can cause the reactive scope calling `Counter()` to track that -source, causing unintentional reruns. As a guard against this, you are forced to -use `untrack()` to create nested reactive scopes. - -The final result is the same as using the `show()` component. An effect is -created which creates the counter, which creates its own reactive scope. The -effect rerunning causes the counter's internal reactive scope to be destroyed, -making sure everything is cleaned up. diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md deleted file mode 100644 index 8628e45..0000000 --- a/docs/tut/control-flow/2-show.md +++ /dev/null @@ -1 +0,0 @@ -# show() diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md deleted file mode 100644 index 647835a..0000000 --- a/docs/tut/control-flow/3-switch.md +++ /dev/null @@ -1 +0,0 @@ -# switch() diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md deleted file mode 100644 index aad2c90..0000000 --- a/docs/tut/control-flow/4-indexes.md +++ /dev/null @@ -1 +0,0 @@ -# indexes() diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md deleted file mode 100644 index 8909904..0000000 --- a/docs/tut/control-flow/5-values.md +++ /dev/null @@ -1 +0,0 @@ -# values() diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 983f51b..564aa2d 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -6,10 +6,16 @@ Vide is heavily inspired by [Solid](https://www.solidjs.com/). ## Why Vide? -Vide provides a reactive and declarative API to simplify managing UI. +Vide's reactive and declarative API aims to let you program UI as simply as +possible, with a strong focus on how data flows through your application. -Some of the main focuses behind Vide's design choices: +Some of Vide's main design choices: -- Minimal syntax -- Complete typechecking -- Independence from instances +- Syntax minimal. +- Data oriented. +- Typechecking compatible. +- Instance independent. + +Vide's reactivity operates with the concept +of scopes which carries a learning curve, though is what makes Vide's minimal +syntax possible. The crash course will introduce these concepts gradually. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index e498c23..e25d451 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -9,10 +9,10 @@ destroyed, or when a stable scope is destroyed. local root = vide.root local source = vide.source local effect = vide.effect +local cleanup = vide.cleanup local count = source(0) - local destroy = root(function() effect(function() local x = count() diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md deleted file mode 100644 index 298ce93..0000000 --- a/docs/tut/crash-course/11-control-flow.md +++ /dev/null @@ -1,98 +0,0 @@ -# Control Flow - -Eventually you may need a way to dynamically create and destroy UI elements -resulting from source updates. Vide provides functions to help you do this, -known as *control flow* functions. - -These functions return new sources, which hold the instances to be displayed. -The new sources can be used in `create()` to update the children of a container -instance. - -## indexes() - -`indexes()` *maps* each table index to a new UI element that can -update to display the current value at that index. Each table index is given a -single corresponding UI element. - -```luau -local list = source { - "finish the crash course", - "star Vide's GitHub" -} - -local function TodoList(props: { list: () -> Array }) - return create "Frame" { - create "UIListLayout" {}, - - indexes(list, function(todo, i) - return create "TextLabel" { - Text = function() - return i .. ": " .. todo() - end, - - LayoutOrder = i - } - end) - } -end - -TodoList { list = list } -``` - -For each index in the given source table, the given function to `indexes()` will -be run in a new stable scope with: - -1. a source containing the value at the index -2. the index itself - -When the value at an index is changed, the function is not reran. Instead, the -given source for that index is updated. - -Any time the input source table is updated, the given function will be ran for -any newly added indexes, while any removed indexes (indexes now with a `nil` -value), will have its corresponding stable scope destroyed. - - - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root ["root scope"] - direction LR - todoList --> indexes -.- subroot1 & subroot2 - - subgraph subroot1 ["indexes scope 1"] - direction LR - value1[todo] --> prop1["prop binding"] - end - - subgraph subroot2 ["indexes scope 2"] - direction LR - value2[todo] --> prop2[prop binding] - end -end -``` - -When you edit a table in a source, you must set that table again to actually -update the source. - -```luau -local src = source { 1, 2 } -local data = src() -table.insert(data, 3) -- no effects will run -src(data) -- effects will run -``` diff --git a/docs/tut/crash-course/11-dynamic-scope.md b/docs/tut/crash-course/11-dynamic-scope.md new file mode 100644 index 0000000..aa2695b --- /dev/null +++ b/docs/tut/crash-course/11-dynamic-scope.md @@ -0,0 +1,158 @@ +# Dynamic Scoping + +Eventually you may need a way to dynamically create and destroy UI elements +resulting from source updates. Vide provides functions to help you do this, +known as *dynamic scope* functions. + +These functions create and destroy components for you in response to source +updates. They return a source containing the created component. This source can +be parented as a child which will update the shown children whenever the source +updates. + +The simplest example is using `show()`. + +```luau +local source = vide.source +local create = vide.create +local show = vide.show +local root = vide.root + +function Button(props: { Text: string, Activated: () -> () }) + return create "TextButton" { + Text = props.Text, + Activated = props.Activated + } +end + +function Menu() + return create "TextLabel" { + Text = "This is a menu" + } +end + +function App() + local toggled = source(false) + + return create "ScreenGui" { + Button { + Text = "Toggle Menu", + Activated = function() + toggled(not toggled()) + end + }, + + show(toggled, Menu) -- [!code highlight] + } +end + +root(function() + App().Parent = game.StarterGui +end) +``` + +This is a complete example of rendering UI which has a single button that +toggles the opening of a menu. + +-------------------------------------------------------------------------------- + +Another common function is `indexes()`. This function creates a component for +each index in a table. + +Each component created is done so in a new and independent stable scope. The +indexes of the table are checked each source update to prevent redunant +destruction and recreation of UI elements. + +```luau +local source = vide.source +local create = vide.create +local indexes = vide.indexes +local root = vide.root + +local function Todo(props: { + Text: () -> string, + Position: number, + Activated: () -> () +}) + return create "TextButton" { + Text = function() return props.Position .. ": " .. props.Text() end, + LayoutOrder = props.Position, + Activated = Activated + } +end + +local function TodoList(props: { List: () -> Array }) + return create "Frame" { + create "UIListLayout" {}, + + indexes(props.List, function(text, i) -- [!code highlight] + return Todo { + Text = text, + Position = i, + Activated = function() -- remove the todo when clicked + local list = props.List() + table.remove(list, i) + props.List(list) + end + } + end) + } +end + +function App() + local list = source { + "finish the crash course", + "star Vide's GitHub" + } + + return create "ScreenGui" { + TodoList { List = list }, + } +end + +root(function() + App().Parent = game.StarterGui +end) +``` + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#111720", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" + } +}}%% + +graph + +subgraph root ["root"] + direction LR + todoList --> indexes -.- subroot1 & subroot2 + + subgraph subroot1 ["indexes scope 1"] + direction LR + value1[todo] --> prop1["prop binding"] + end + + subgraph subroot2 ["indexes scope 2"] + direction LR + value2[todo] --> prop2[prop binding] + end +end +``` + +When you edit a table in a source, you must set that table again to actually +update the source. + +```luau +local src = source { 1, 2 } +local data = src() +table.insert(data, 3) -- no effects will run +src(data) -- effects will run +``` diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index 64d3fce..ba02618 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -1,13 +1,11 @@ # Actions -Actions in Vide are special callbacks that you can pass along with properties, +Actions are special callbacks that you can pass along with properties, to run some code on an instance receiving them. ```luau local action = vide.action -``` -```luau create "TextLabel" { Text = "test", @@ -24,6 +22,7 @@ action used to listen for property changes: ```luau local action = vide.action +local source = vide.source local effect = vide.effect local cleanup = vide.cleanup @@ -49,7 +48,7 @@ effect(function() print(output()) end) -instance.Text = "foo" -- "foo" will be printed from the effect +instance.Text = "foo" -- "foo" will be printed by the effect ``` The source `output` will be updated with the new property value any time it is diff --git a/docs/tut/crash-course/13-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md index f2b30fe..0de8887 100644 --- a/docs/tut/crash-course/13-strict-mode.md +++ b/docs/tut/crash-course/13-strict-mode.md @@ -23,6 +23,7 @@ local count = source(0) local ran = 0 effect(function() + count() ran += 1 end) diff --git a/docs/tut/crash-course/14-concepts.md b/docs/tut/crash-course/14-concepts.md index 77e9e7f..8ed6a1a 100644 --- a/docs/tut/crash-course/14-concepts.md +++ b/docs/tut/crash-course/14-concepts.md @@ -30,7 +30,7 @@ Created by: - `root()` - `untrack()` -- `switch()` +- `show()` - `indexes()` Stable scopes do not track sources and never rerun. @@ -47,23 +47,14 @@ Created by: Reactive scopes do track sources and will rerun when those sources update. Reactive scopes cannot be created within a reactive scope, but stable scopes -can. +can be created within a reactive scope. -## Scope Owners +## Scope Cleanup -A scope created within another scope is *owned* by the other scope, with the -exception of the scope created by `root()`. +When a scope is rerun or destroyed, all scopes created within it are +automatically destroyed. -When a scope is rerun or destroyed, all scopes owned by it are automatically -destroyed. - -`root()` creates a stable scope with no owner, instead it is destroyed manually. - -## Cleanup - -Arbitrary code to run whenever a stable or reactive scope is rerun or destroyed. - -Queue a function to run using `cleanup()`. +Any functions queued by `cleanup()` are also ran. ## Reactive Graph @@ -93,12 +84,12 @@ end) %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#202530" } }}%% @@ -118,6 +109,5 @@ Notes: - An update to `count` will cause `text` to rerun, which then causes `effect` to rerun. - When the root scope is destroyed, `text` and - `effect` will be destroyed alongside it, since they are - owned by it. `count` will be untouched and future updates - to `count` will have no effect. + `effect` will be destroyed alongside it, since they were created within it. + `count` will be untouched and future updates to `count` will have no effect. diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 29e6358..e435034 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,6 +1,6 @@ # Effects -Effects are functions that are ran in response to source updates. They are +Effects are functions that are ran in response to source updates. A source and effect is analogous to a signal and connection. Effects are created using `effect()`. @@ -23,7 +23,10 @@ count(1) Any source read inside an effect is tracked and will rerun the effect when that source is updated. -Derived sources are also tracked, it doesn't matter how deeply nested +The effect runs its callback once immediately to initially figure out what +sources are being read. + +Derived sources are also tracked, it does not matter how deeply nested inside a function a source is. ```luau @@ -47,3 +50,22 @@ count(2) If a source is updated with the same value it already had, it will not rerun effects depending on it. + +You can also read from a source within an effect without the effect tracking it. + +```lua +local source = vide.source +local effect = vide.effect +local untrack = vide.untrack + +local a = source(0) +local b = source(0) + +effect(function() + print(`a: {a()} b: {untrack(b)}`) +end) + +a(1) -- prints "a: 1 b: 0" +b(1) -- prints nothing +a(2) -- prints "a: 2 b: 1" +``` diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index 6c12088..a222e50 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -7,29 +7,31 @@ But the disconnecting of many signals and connections is tedious and verbose. Vide instead operates on the concept of scopes which provides a much cleaner API, given that you follow a few rules. -Scopes come in two flavors; stable and reactive. +Thre are two types of scopes: stable and reactive. -- All scopes must be created within another scope with the exception of `root()` -- Stable scopes never rerun -- Reactive scopes can rerun -- A reactive scope cannot be created within another reactive scope +- A scope must be created within another scope. +- Stable scopes never rerun. +- Reactive scopes can rerun. +- A reactive scope cannot be created within another reactive scope, only within + a stable scope. + +An exception to the first rule is `root()`, which creates the initial scope that +you destroy manually with a destructor function it returns. -`effect()` creates a reactive scope. `root()` creates a stable scope. +`effect()` creates a reactive scope. Whenever a scope is destroyed, any scope created within that scope is also -destroyed, and so on. This is why all scopes must be created within another -scope, except `root()` which is used to create the initial scope that you can -manually destroy. +destroyed, and so on. ```luau local root = vide.root local source = vide.source local effect = vide.effect -local function setup() - local count = source(0) +local count = source(0) +local function setup() effect(function() print(count()) end) @@ -37,32 +39,16 @@ local function setup() return count end -setup() -- will error since effect() tries to create a reactive scope outside of a stable scope +setup() -- error, effect() tried to create a reactive scope with no stable scope -local count = root(setup) -- ok since effect() was called within a stable scope -count(1) -- prints "1" -``` - -The scope created by `root()` can be destroyed. - -```luau -local function setup() - local count = source(0) - - effect(function() - print(count()) - end) - - return count -end - -local destroy, count = root(setup) +local destroy = root(setup) -- ok since effect() was called in a stable scope count(1) -- prints "1" +count(2) -- prints "2" destroy() -count(2) -- effect is destroyed; no longer prints +count(3) -- reactive scope created by effect() is destroyed, it does not rerun ``` Vide's reactivity can be represented graphically, as a *reactive graph*. @@ -73,12 +59,12 @@ The reactive graph for the above example looks like so: %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" } }}%% @@ -90,7 +76,7 @@ subgraph root end ``` -When the stable `root()` is destroyed, the reactive `effect()` +When the stable `root()` scope is destroyed, the reactive `effect()` scope will also be destroyed since it was created within it. This is important because you may have an effect that updates the property of a diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-reactive-component.md similarity index 91% rename from docs/tut/crash-course/7-stateful-component.md rename to docs/tut/crash-course/7-reactive-component.md index 5126c73..ff67ec8 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-reactive-component.md @@ -1,10 +1,8 @@ -# Stateful Components +# Reactive Components -Stateful components in Vide are created using sources and effects - sources to +Reactive components in Vide are created using sources and effects - sources to store the data, and effects to display the data. -## Internal State - ```luau local create = vide.create local source = vide.source @@ -33,8 +31,6 @@ its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the component. -## External State - External sources can also be passed into components for them to use. ```luau diff --git a/docs/tut/crash-course/8-implicit-effect.md b/docs/tut/crash-course/8-implicit-effect.md index cdb75fb..3363bf6 100644 --- a/docs/tut/crash-course/8-implicit-effect.md +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -3,7 +3,9 @@ Explicitly creating effects to update properties is tedious. You can *implicitly* create an effect to update properties instead. -```luau +::: code-group + +```luau [Implicit Effect] local create = vide.create local source = vide.source @@ -22,6 +24,30 @@ local function Counter() end ``` +```luau [Explicit Effect] +local create = vide.create +local source = vide.source +local effect = vide.effect + +local function Counter() + local count = source(0) + + local instance = create "TextButton" { + Activated = function() + count(count() + 1) + end + } + + effect(function() + instance.Text = "count: " .. count() + end) + + return instance +end +``` + +::: + This example is equivalent to the example seen on the previous page. Instead of explicitly creating an effect, assigning a (non-event) property a @@ -46,12 +72,12 @@ local function List(props: { children: () -> { Instance } }) } end -local list = List { children = items } -- creates a list with a single text label "A" +local list = List { children = items } -- creates a list with text label "A" items { create "TextLabel" { Text = "B" }, create "TextLabel" { Text = "C" } } --- this will automatically unparent the text label "A", and parent the labels "B" and "C" +-- this will automatically unparent text label "A", and parent labels "B" and "C" ``` diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 19a6a5d..694d90b 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -57,7 +57,8 @@ effect(function() text() end) count(1) -- prints "ran" x1 ``` -`derive()` must also be called within a stable scope, just like `effect()`. +Because `derive()` creates a reactive scope, it must be called within a stable +scope, just like `effect()`. If the recalculated value is the same as the old value, the derived source will not rerun the effects using it. @@ -68,12 +69,12 @@ The reactive graph for the above example: %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" } }}%% @@ -86,7 +87,7 @@ end ``` Deriving a source in this manner is similar to creating an effect to update -another source. You should never manually do this using an effect however. +another source. You should avoid doing this using an effect however. Improper usage could accidently create infinite loops in the reactive graph. Always favour deriving when you need one source to update based on another source. diff --git a/docs/tut/dynamic-scoping/custom.md b/docs/tut/dynamic-scoping/custom.md new file mode 100644 index 0000000..fc4258b --- /dev/null +++ b/docs/tut/dynamic-scoping/custom.md @@ -0,0 +1,144 @@ +# Dynamic Scoping + +Dynamic scoping is the act of creating and destroying new scopes in response to +source updates. This is needed for conditionally rendering parts of your UI, +such as opening and closing menus. + +While Vide provides functions for common ways to do this, this section will +show how you can implement them yourself so you are not limited by only what is +provided. + +## Recreating [`show()`](/api/reactivity-dynamic#show-reactive) + +The most basic one, `show()`, can be +implemented yourself like so: + +```luau +local function show(toggle: () -> unknown, component: () -> Instance) + return derive(function() + return if toggle() then untrack(component) else nil + end) +end +``` + +The main thing to note here is the use of `untrack()`. This function runs its +callback in a new stable scope. Without this, if the component were to create +a reactive scope, an error would occur since a reactive scope cannot be created +within a reactive scope. + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#444455", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#444455" + } +}}%% + +graph + +subgraph derive ["derive (reactive)"] + + subgraph untrack ["untrack (stable)"] + subgraph effect ["effect (reactive)"] + + end + end +end +``` + +You can see from the above graph how the effect would not be created directly +inside the derive, there is a stable scope between them. This requirement exists +as a guard against unintentional rerendering of UI. + +## Recreating [`switch()`](/api/reactivity-dynamic#switch-reactive) + +```lua +local function switch(key) + return function(map) + return derive(function() + local component = map[key()] + return if component then untrack(component) else nil + end) + end +end +``` + +## Recreating [`indexes()`](/api/reactivity-dynamic#indexes-reactive) + +This is a more complicated function because it manages multiple scopes at the +same time, unlike the previous functions. Because some scopes may persist +between reruns, we cannot use `untrack()` anymore which automatically destroys +on rerun; we must use `root()` where the lifetime of each scope is managed +manually and independently. + + +```lua +local function indexes( + input: () -> Map, + transform: (value: () -> VI, index: I) -> VO +) + local index_caches = {} :: Map VI, + destroy: () -> () + }?> + + return derive(function() + local new_input = input() + + -- destroy scopes of removed indexes + for i, cache in index_caches do + if new_input[i] == nil then + assert(cache).destroy() + index_caches[i] = nil + end + end + + -- create scopes or update sources of added or changed index values + for i, v in new_input do + local cache = index_caches[i] + + if cache == nil then -- no scope created for this index, create one + local src = source(v) + + local destroy, result = root(function() + return transform(src, i) + end) + + index_caches[i] = { + destroy = destroy, + source = src, + output = result, + previous_input = v + } + elseif cache.previous_input ~= v then -- scope exists, update source + cache.previous_input = v + cache.source(v) + else -- scope exists and value has not changed; do nothing + end + end + + -- return the cached output values as an array + local array = table.create(#index_caches) + + for _, cache in index_caches do + table.insert(array, assert(cache).output) + end + + return array + end) +end +``` + +-------------------------------------------------------------------------------- + +Though the above functions are already provided to you by Vide, this serves as +an example for how you may create your own dynamic scope functions. + + From baf308ccf34370ea13107319e646e621900c50f3 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Mon, 4 Nov 2024 23:27:28 +0000 Subject: [PATCH 42/94] Fix bug in code example --- docs/tut/dynamic-scoping/custom.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/tut/dynamic-scoping/custom.md b/docs/tut/dynamic-scoping/custom.md index fc4258b..7a2ee62 100644 --- a/docs/tut/dynamic-scoping/custom.md +++ b/docs/tut/dynamic-scoping/custom.md @@ -76,7 +76,6 @@ between reruns, we cannot use `untrack()` anymore which automatically destroys on rerun; we must use `root()` where the lifetime of each scope is managed manually and independently. - ```lua local function indexes( input: () -> Map, @@ -89,6 +88,13 @@ local function indexes( destroy: () -> () }?> + -- destroy all scopes if the parent scope is destroyed + cleanup(function() + for _, cache in index_caches do + assert(cache).destroy() + end + end) + return derive(function() local new_input = input() @@ -140,5 +146,3 @@ end Though the above functions are already provided to you by Vide, this serves as an example for how you may create your own dynamic scope functions. - - From bacb4fa0f0d8e407fe2467f7819798ba0f28fd02 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Tue, 12 Nov 2024 15:08:12 +0000 Subject: [PATCH 43/94] Fix mistakes in docs --- docs/.vitepress/config.ts | 2 +- docs/api/reactivity-dynamic.md | 2 +- docs/tut/{dynamic-scoping => advanced}/custom.md | 4 ++-- docs/tut/crash-course/12-actions.md | 4 ++-- docs/tut/crash-course/14-concepts.md | 2 +- docs/tut/crash-course/5-effect.md | 2 +- docs/tut/crash-course/6-scope.md | 2 -- docs/tut/crash-course/7-reactive-component.md | 12 ++++-------- 8 files changed, 12 insertions(+), 18 deletions(-) rename docs/tut/{dynamic-scoping => advanced}/custom.md (99%) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 380542a..633059e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -65,7 +65,7 @@ export default withMermaid({ { text: "Dynamic Scoping", items: [ - { text: "Custom Scopes", link: "/tut/dynamic-scoping/custom"} + { text: "Custom Scopes", link: "/tut/advanced/custom"} ] }, { diff --git a/docs/api/reactivity-dynamic.md b/docs/api/reactivity-dynamic.md index a3e5168..875c12f 100644 --- a/docs/api/reactivity-dynamic.md +++ b/docs/api/reactivity-dynamic.md @@ -32,7 +32,7 @@ Shows one of a set of components depending on a source and a mapping table. - **Type** ```luau - function switch(source: () -> K): (map: Map V>) () -> V? + function switch(source: () -> K): (map: Map V>): () -> V? ``` - **Details** diff --git a/docs/tut/dynamic-scoping/custom.md b/docs/tut/advanced/custom.md similarity index 99% rename from docs/tut/dynamic-scoping/custom.md rename to docs/tut/advanced/custom.md index 7a2ee62..99fe746 100644 --- a/docs/tut/dynamic-scoping/custom.md +++ b/docs/tut/advanced/custom.md @@ -57,7 +57,7 @@ as a guard against unintentional rerendering of UI. ## Recreating [`switch()`](/api/reactivity-dynamic#switch-reactive) -```lua +```luau local function switch(key) return function(map) return derive(function() @@ -76,7 +76,7 @@ between reruns, we cannot use `untrack()` anymore which automatically destroys on rerun; we must use `root()` where the lifetime of each scope is managed manually and independently. -```lua +```luau local function indexes( input: () -> Map, transform: (value: () -> VI, index: I) -> VO diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index ba02618..60e5c30 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -26,9 +26,9 @@ local source = vide.source local effect = vide.effect local cleanup = vide.cleanup -local function changed(prop: string, callback: (new) -> ()) +local function changed(property: string, callback: (new) -> ()) return action(function(instance) - local connection = instance:GetPropertyChangedSignal(prop):Connect(function() + local connection = instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) diff --git a/docs/tut/crash-course/14-concepts.md b/docs/tut/crash-course/14-concepts.md index 8ed6a1a..f33d043 100644 --- a/docs/tut/crash-course/14-concepts.md +++ b/docs/tut/crash-course/14-concepts.md @@ -69,7 +69,7 @@ local count = source(0) root(function() local text = derive(function() - return "count: " .. text() + return "count: " .. count() end) effect(function() diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index e435034..82768dc 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -53,7 +53,7 @@ effects depending on it. You can also read from a source within an effect without the effect tracking it. -```lua +```luau local source = vide.source local effect = vide.effect local untrack = vide.untrack diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index a222e50..7775647 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -35,8 +35,6 @@ local function setup() effect(function() print(count()) end) - - return count end setup() -- error, effect() tried to create a reactive scope with no stable scope diff --git a/docs/tut/crash-course/7-reactive-component.md b/docs/tut/crash-course/7-reactive-component.md index ff67ec8..339ee4f 100644 --- a/docs/tut/crash-course/7-reactive-component.md +++ b/docs/tut/crash-course/7-reactive-component.md @@ -34,14 +34,10 @@ count source is created inside the component. External sources can also be passed into components for them to use. ```luau -local function Counter(props: { count: () -> number }) +local function CountDisplay(props: { count: () -> number }) local count = props.count - local instance = create "TextButton" { - Activated = function() - count(count() + 1) - end - } + local instance = create "TextLabel" {} effect(function() instance.Text = "count: " .. count() @@ -52,11 +48,11 @@ end local count = source(0) -Counter { +CountDisplay { count = count } -count(1) -- the Counter component will update to display this count +count(1) -- the CountDisplay component will update to display this count ``` Sources can be created internally or passed in from externally, there are no From a1552402cb696c0087b06bcc36d648d1be4ec3fd Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Tue, 12 Nov 2024 15:15:16 +0000 Subject: [PATCH 44/94] Rename tutorial sections --- docs/.vitepress/config.ts | 6 +++--- docs/tut/advanced/{custom.md => dynamic-scopes.md} | 0 .../{11-dynamic-scope.md => 11-dynamic-scopes.md} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename docs/tut/advanced/{custom.md => dynamic-scopes.md} (100%) rename docs/tut/crash-course/{11-dynamic-scope.md => 11-dynamic-scopes.md} (99%) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 633059e..37918e8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -56,16 +56,16 @@ export default withMermaid({ { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, - { text: "Dynamic Scoping", link: "/tut/crash-course/11-dynamic-scope" }, + { text: "Dynamic Scopes", link: "/tut/crash-course/11-dynamic-scopes" }, { text: "Actions", link: "/tut/crash-course/12-actions" }, { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } ] }, { - text: "Dynamic Scoping", + text: "Advanced", items: [ - { text: "Custom Scopes", link: "/tut/advanced/custom"} + { text: "Dynamic Scopes", link: "/tut/advanced/dynamic-scopes"} ] }, { diff --git a/docs/tut/advanced/custom.md b/docs/tut/advanced/dynamic-scopes.md similarity index 100% rename from docs/tut/advanced/custom.md rename to docs/tut/advanced/dynamic-scopes.md diff --git a/docs/tut/crash-course/11-dynamic-scope.md b/docs/tut/crash-course/11-dynamic-scopes.md similarity index 99% rename from docs/tut/crash-course/11-dynamic-scope.md rename to docs/tut/crash-course/11-dynamic-scopes.md index aa2695b..bede8d8 100644 --- a/docs/tut/crash-course/11-dynamic-scope.md +++ b/docs/tut/crash-course/11-dynamic-scopes.md @@ -1,4 +1,4 @@ -# Dynamic Scoping +# Dynamic Scopes Eventually you may need a way to dynamically create and destroy UI elements resulting from source updates. Vide provides functions to help you do this, From 0897821e1fa3847b43d278fb6a287540ff55c3ea Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 13 Nov 2024 21:56:22 +0000 Subject: [PATCH 45/94] Add check for destruction of active scope --- docs/api/strict-mode.md | 3 ++- src/graph.luau | 8 ++++++- test/tests.luau | 53 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index d3ce5cb..630a7bd 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -19,7 +19,8 @@ Currently, strict mode will: 3. Checks for `indexes()` and `values()` outputting primitive values. 4. Checks for `values()` input having duplicate values. 5. Checks for duplicate nested properties at same depth. -6. Better error reporting and stack traces + creation traces of property bindings. +6. Checks for destruction of an active scope. +7. Better error reporting and stack traces + creation traces of property bindings. By rerunning reactive scopes twice each time they update, it helps ensure that computations are pure, and that any cleanup is done correctly. diff --git a/src/graph.luau b/src/graph.luau index 9648180..1495a59 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -108,6 +108,10 @@ local function unparent(node: Node) end local function destroy(node: Node) + if flags.strict and table.find(scopes, node) then + throw("attempt to destroy an active scope") + end + flush_cleanups(node) unparent(node) @@ -296,5 +300,7 @@ return table.freeze { flush_update_queue = flush_update_queue, get_update_queue_length = get_update_queue_length, set_context = set_context, - scopes = scopes + scopes = scopes, + + q = update_queue } diff --git a/test/tests.luau b/test/tests.luau index 45aa41f..395b568 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2483,11 +2483,14 @@ end)) TEST("strict", wrap_root(function() vide.strict = true + local root = vide.root + local show = vide.show local create = vide.create local source = vide.source local derive = vide.derive local effect = vide.effect local indexes, values = vide.indexes, vide.values + local untrack = vide.untrack do CASE "error on derived callback yield" local src = source(1) @@ -2627,6 +2630,56 @@ TEST("strict", wrap_root(function() CHECK(count == 4) end + + do CASE "destruction of active scope" + local src = source(false) + + root(function() + show(src, function() + src(false) + return {} + end) + end) + + local ok = pcall(function() + src(true) + end) + + CHECK(not ok) + end + + -- todo: review intended behavior here + -- do CASE "destruction of active scope in indexes" + -- local src = source {} + + -- local tmp + -- root(function() + -- effect(function() + -- untrack(function() + -- tmp = indexes(src, function() + -- vide.cleanup(function() print "test" end) + -- src {} + -- print "updated" + -- vide.cleanup(function() print "test2" end) + -- print "end" + -- return {} + -- end) + -- return nil + -- end) + -- end) + -- end) + + -- print "setting" + + -- local ok = pcall(function() + -- src { 1 } + -- print "done" + -- end) + + -- print(#tmp()) + + -- CHECK(not ok) + -- end end)) local ok = FINISH() From 5abd5eee91ad01a79afacdba802766180d965ed2 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:19:20 +0000 Subject: [PATCH 46/94] Update require paths to relative string --- src/apply.luau | 17 ++++++++--------- src/batch.luau | 8 +++----- src/bind.luau | 4 +--- src/changed.luau | 6 ++---- src/cleanup.luau | 7 +++---- src/context.luau | 6 +++--- src/create.luau | 12 ++++++------ src/defaults.luau | 6 +++--- src/derive.luau | 4 +--- src/effect.luau | 4 +--- src/graph.luau | 6 ++---- src/init.luau | 42 ++++++++++++++++++++---------------------- src/maps.luau | 8 +++----- src/mount.luau | 6 ++---- src/read.luau | 2 -- src/root.luau | 6 ++---- src/show.luau | 4 +--- src/source.luau | 4 +--- src/spring.luau | 7 +++---- src/switch.luau | 6 ++---- src/throw.luau | 2 -- src/untrack.luau | 4 +--- test/benchmark.luau | 20 ++++++++++---------- test/spring-test.luau | 4 ++-- test/tests.luau | 18 +++++++++--------- 25 files changed, 89 insertions(+), 124 deletions(-) diff --git a/src/apply.luau b/src/apply.luau index 0f4586e..abb482c 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,13 +1,12 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof :: never -local Vector2 = game and Vector2 or require "test/mock".Vector2 :: never -local UDim2 = game and UDim2 or require "test/mock".UDim2 :: never +local typeof = game and typeof or require "../test/mock".typeof :: never +local Vector2 = game and Vector2 or require "../test/mock".Vector2 :: never +local UDim2 = game and UDim2 or require "../test/mock".UDim2 :: never -local flags = require(script.Parent.flags) -local throw = require(script.Parent.throw) -local bind = require(script.Parent.bind) -local _, is_action = require(script.Parent.action)() -local graph = require(script.Parent.graph) +local flags = require "./flags" +local throw = require "./throw" +local bind = require "./bind" +local _, is_action = require "./action"() +local graph = require "./graph" type Node = graph.Node type Array = { V } diff --git a/src/batch.luau b/src/batch.luau index 45ab038..4cd7581 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -1,8 +1,6 @@ -if not game then script = require "test/relative-string" end - -local flags = require(script.Parent.flags) -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local flags = require "./flags" +local throw = require "./throw" +local graph = require "./graph" local function batch(setter: () -> ()) local already_batching = flags.batch diff --git a/src/bind.luau b/src/bind.luau index 3a016a6..ff32bcb 100644 --- a/src/bind.luau +++ b/src/bind.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node local assert_stable_scope = graph.assert_stable_scope diff --git a/src/changed.luau b/src/changed.luau index 519a554..063f7e9 100644 --- a/src/changed.luau +++ b/src/changed.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local action = require(script.Parent.action)() -local cleanup = require(script.Parent.cleanup) +local action = require "./action"() +local cleanup = require "./cleanup" local function changed(property: string, callback: (T) -> ()) return action(function(instance) diff --git a/src/cleanup.luau b/src/cleanup.luau index 8449fcc..54195fb 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,8 +1,7 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof :: never +local typeof = game and typeof or require "../test/mock".typeof :: never -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local throw = require "./throw" +local graph = require "./graph" local get_scope = graph.get_scope local push_cleanup = graph.push_cleanup diff --git a/src/context.luau b/src/context.luau index 6c571d0..882b838 100644 --- a/src/context.luau +++ b/src/context.luau @@ -1,7 +1,7 @@ -if not game then script = require "test/relative-string" end +if not game then script = require "../test/relative-string" end -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local throw = require "./throw" +local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node local get_scope = graph.get_scope diff --git a/src/create.luau b/src/create.luau index 7adfc68..5b6c9df 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,10 +1,10 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof:: never -local Instance = game and Instance or require "test/mock".Instance :: never +if not game then script = require "../test/relative-string" end +local typeof = game and typeof or require "../test/mock".typeof :: never +local Instance = game and Instance or require "../test/mock".Instance :: never -local throw = require(script.Parent.throw) -local defaults = require(script.Parent.defaults) -local apply = require(script.Parent.apply) +local throw = require "./throw" +local defaults = require "./defaults" +local apply = require "./apply" local ctor_cache = {} :: { [string]: () -> Instance } diff --git a/src/defaults.luau b/src/defaults.luau index 03badd6..ff2a256 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,6 +1,6 @@ -local Enum = game and Enum or require "test/mock".Enum :: never -local Color3 = game and Color3 or require "test/mock".Color3 :: never -local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never +local Enum = game and Enum or require "../test/mock".Enum :: never +local Color3 = game and Color3 or require "../test/mock".Color3 :: never +local Vector3 = game and Vector3 or require "../test/mock".Vector3 :: never return { Part = { diff --git a/src/derive.luau b/src/derive.luau index fb824a4..73bbfeb 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" local create_node = graph.create_node local push_child_to_scope = graph.push_child_to_scope local assert_stable_scope = graph.assert_stable_scope diff --git a/src/effect.luau b/src/effect.luau index 3acab21..5a2cd25 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" local create_node = graph.create_node local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node diff --git a/src/graph.luau b/src/graph.luau index 1495a59..be58b15 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local flags = require(script.Parent.flags) +local throw = require "./throw" +local flags = require "./flags" export type SourceNode = { cache: T, diff --git a/src/init.luau b/src/init.luau index 3bcc7ad..79cbf75 100644 --- a/src/init.luau +++ b/src/init.luau @@ -4,28 +4,26 @@ local version = { major = 0, minor = 3, patch = 1 } -if not game then script = require "test/relative-string" end - -local root = require(script.root) -local mount = require(script.mount) -local create = require(script.create) -local apply = require(script.apply) -local source = require(script.source) -local effect = require(script.effect) -local derive = require(script.derive) -local cleanup = require(script.cleanup) -local untrack = require(script.untrack) -local read = require(script.read) -local batch = require(script.batch) -local context = require(script.context) -local switch = require(script.switch) -local show = require(script.show) -local indexes, values = require(script.maps)() -local spring, update_springs = require(script.spring)() -local action = require(script.action)() -local changed = require(script.changed) -local throw = require(script.throw) -local flags = require(script.flags) +local root = require "./root" +local mount = require "./mount" +local create = require "./create" +local apply = require "./apply" +local source = require "./source" +local effect = require "./effect" +local derive = require "./derive" +local cleanup = require "./cleanup" +local untrack = require "./untrack" +local read = require "./read" +local batch = require "./batch" +local context = require "./context" +local switch = require "./switch" +local show = require "./show" +local indexes, values = require "./maps"() +local spring, update_springs = require "./spring"() +local action = require "./action"() +local changed = require "./changed" +local throw = require "./throw" +local flags = require "./flags" export type Source = source.Source export type source = Source diff --git a/src/maps.luau b/src/maps.luau index 23c1eb3..5fea836 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -1,8 +1,6 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local flags = require(script.Parent.flags) -local graph = require(script.Parent.graph) +local throw = require "./throw" +local flags = require "./flags" +local graph = require "./graph" type Node = graph.Node type SourceNode = graph.SourceNode local create_node = graph.create_node diff --git a/src/mount.luau b/src/mount.luau index b9d0ace..567d3d9 100644 --- a/src/mount.luau +++ b/src/mount.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local root = require(script.Parent.root) -local apply = require(script.Parent.apply) +local root = require "./root" +local apply = require "./apply" local function mount(component: () -> T, target: Instance?): () -> () return root(function() diff --git a/src/read.luau b/src/read.luau index d3a2fb7..3764315 100644 --- a/src/read.luau +++ b/src/read.luau @@ -1,5 +1,3 @@ -if not game then script = require "test/relative-string" end - local function read(value: T | () -> T): T return if type(value) == "function" then value() else value end diff --git a/src/root.luau b/src/root.luau index bc5904d..d8dc230 100644 --- a/src/root.luau +++ b/src/root.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local throw = require "./throw" +local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node local push_scope = graph.push_scope diff --git a/src/show.luau b/src/show.luau index 1cf60ca..b3c3fef 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local switch = require(script.Parent.switch) +local switch = require "./switch" local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? local function truthy() diff --git a/src/source.luau b/src/source.luau index e326815..0b89637 100644 --- a/src/source.luau +++ b/src/source.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local create_source_node = graph.create_source_node local push_child_to_scope = graph.push_child_to_scope diff --git a/src/spring.luau b/src/spring.luau index a01ac1c..063b186 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,5 +1,4 @@ -if not game then script = require "test/relative-string" end -local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never +local Vector3 = game and Vector3 or require "../test/mock".Vector3 :: never --[[ @@ -21,8 +20,8 @@ Unsupported datatypes: ]] -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local throw = require "./throw" +local graph = require "./graph" type Node = graph.Node type SourceNode = graph.SourceNode local create_node = graph.create_node diff --git a/src/switch.luau b/src/switch.luau index 99edd3c..cb7cba4 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local throw = require "./throw" +local graph = require "./graph" type Node = graph.Node type SourceNode = graph.SourceNode local create_node = graph.create_node diff --git a/src/throw.luau b/src/throw.luau index 954f3e2..4135f69 100644 --- a/src/throw.luau +++ b/src/throw.luau @@ -1,5 +1,3 @@ -if not game then script = require "test/relative-string" end - local function VIDE_ASSERT(msg): any error(msg, 0) end diff --git a/src/untrack.luau b/src/untrack.luau index 86cdb7b..90ceee2 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local get_scope = graph.get_scope diff --git a/test/benchmark.luau b/test/benchmark.luau index 05fe3ef..e61f05b 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -1,7 +1,7 @@ -local testkit = require("test/testkit") +local testkit = require("./testkit") local BENCH, START = testkit.benchmark() -local vide = require "src/init" +local vide = require "../src/init" local source = vide.source local derive = vide.derive local effect = vide.effect @@ -200,7 +200,7 @@ end) TITLE "property apply" ROOT_BENCH("apply 0 properties", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -209,7 +209,7 @@ ROOT_BENCH("apply 0 properties", function() end) ROOT_BENCH("apply 8 properties", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -227,7 +227,7 @@ ROOT_BENCH("apply 8 properties", function() end) ROOT_BENCH("bind property", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -242,7 +242,7 @@ ROOT_BENCH("bind property", function() end) ROOT_BENCH("update binding", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -512,8 +512,8 @@ do -- the purpose of the two following benchmarks is to measure the overhead of -- aggregate construction ROOT_BENCH("set explicit mock vector2", function() - local apply = require "src/apply" - local Vector2 = require "test/mock".Vector2 + local apply = require "../src/apply" + local Vector2 = require "../test/mock".Vector2 local label = create "TextLabel" { AnchorPoint = Vector2.new(1, 1) @@ -527,8 +527,8 @@ do end) ROOT_BENCH("set aggregate mock vector2", function() - local apply = require "src/apply" - local Vector2 = require "test/mock".Vector2 + local apply = require "../src/apply" + local Vector2 = require "../test/mock".Vector2 local label = create "TextLabel" { AnchorPoint = Vector2.new(1, 1) diff --git a/test/spring-test.luau b/test/spring-test.luau index 2e3bc9b..a849598 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -1,5 +1,5 @@ -local vide = require "src/init" -local testkit = require("test/testkit") +local vide = require "../src/init" +local testkit = require("../test/testkit") local program_time = os.clock() diff --git a/test/tests.luau b/test/tests.luau index 395b568..42f9563 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,12 +1,12 @@ -local testkit = require("test/testkit") +local testkit = require "./testkit" local TEST, CASE, CHECK, FINISH = testkit.test() -local mock = require "test/mock" +local mock = require "./mock" local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 -local vide = require "src/init" -local graph = require "src/graph" +local vide = require "../src/init" +local graph = require "../src/graph" type Node = graph.Node type Map = { [K] : V } @@ -722,7 +722,7 @@ TEST("create()", wrap_root(function() local cleanup = vide.cleanup do CASE "apply default properties" - local defaults = require("src/defaults") + local defaults = require "../src/defaults" local frame = create "Frame" {} :: Instance & { BorderSizePixel: any, BorderColor3: any } CHECK(frame.BorderSizePixel == defaults.Frame.BorderSizePixel) CHECK(frame.BorderColor3 == defaults.Frame.BorderColor3) @@ -1169,7 +1169,7 @@ TEST("switch()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source(1) local n0 = scopes.n @@ -1328,7 +1328,7 @@ TEST("indexes()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source { 1 } @@ -1507,7 +1507,7 @@ TEST("values()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source { 1 } @@ -2260,7 +2260,7 @@ TEST("context()", function() end) TEST("nested effects cases", function() - local vide = require "src/init" + local vide = require "../src/init" local source = vide.source local effect = vide.effect local untrack = vide.untrack From 49cc55149387dd0df5fb9a1afd690bc0563b0782 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:35:16 +0000 Subject: [PATCH 47/94] Switch from mock Vector3 to native vector lib --- src/apply.luau | 4 +- src/defaults.luau | 3 +- src/spring.luau | 8 ++-- test/benchmark.luau | 97 +++++++++++++++++++++------------------------ test/mock.luau | 35 ---------------- 5 files changed, 52 insertions(+), 95 deletions(-) diff --git a/src/apply.luau b/src/apply.luau index abb482c..1598f3b 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -70,12 +70,14 @@ for name, class in { UDim = UDim, UDim2 = UDim2, Vector2 = Vector2, - Vector3 = Vector3, Rect = Rect } :: Map do aggregates[name] = class.new end +aggregates.Vector3 = vector.create +aggregates.vector = vector.create + -- applies table of nested properties to an instance using full vide semantics local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T if not properties then diff --git a/src/defaults.luau b/src/defaults.luau index ff2a256..bcdabaf 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,11 +1,10 @@ local Enum = game and Enum or require "../test/mock".Enum :: never local Color3 = game and Color3 or require "../test/mock".Color3 :: never -local Vector3 = game and Vector3 or require "../test/mock".Vector3 :: never return { Part = { Material = Enum.Material.SmoothPlastic, - Size = Vector3.new(1, 1, 1), + Size = vector.create(1, 1, 1), Anchored = true }, diff --git a/src/spring.luau b/src/spring.luau index 063b186..acb50ac 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,5 +1,3 @@ -local Vector3 = game and Vector3 or require "../test/mock".Vector3 :: never - --[[ Supported datatypes: @@ -36,8 +34,8 @@ local TOLERANCE = 0.0001 type Vec3 = Vector3 -local function Vec3(x: number?, y: number?, z: number?) - return Vector3.new(x, y, z) +local function Vec3(x: number?, y: number?, z: number?): Vec3 + return vector.create(x, y, z) end local ZERO = Vec3(0, 0, 0) @@ -276,7 +274,7 @@ local function update_spring_sources() x0_456 - x1_456 -- todo: can this false positive? - if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then + if vector.magnitude(v_123 + v_456 + dx_123 + dx_456) < TOLERANCE then -- close enough to target, unshedule spring and set value to target table.insert(remove_queue, data) output.cache = data.source_value diff --git a/test/benchmark.luau b/test/benchmark.luau index e61f05b..0177e58 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -504,85 +504,78 @@ ROOT_BENCH(`get context (depth={depth})`, function() end) end) -N *= 1024 +TITLE "spring()" + +ROOT_BENCH("spring update", function() + local root, source, spring = vide.root, vide.source, vide.spring + + local src = source(0) + + root(function() + for i = 1, N do + spring(src) + end + + START(N) + + src(1) + + return nil + end) +end) + +ROOT_BENCH("spring step", function() + local root, source, spring = vide.root, vide.source, vide.spring + + local src = source(0) + + root(function() + for i = 1, N do + spring(src) + end + + src(1) + + START(N) + + vide.step(1/60) + + return nil + end) +end) TITLE "aggregate" do -- the purpose of the two following benchmarks is to measure the overhead of -- aggregate construction - ROOT_BENCH("set explicit mock vector2", function() + ROOT_BENCH("set explicit vector", function() local apply = require "../src/apply" - local Vector2 = require "../test/mock".Vector2 local label = create "TextLabel" { - AnchorPoint = Vector2.new(1, 1) + AnchorPoint = vector.create(1, 1, 1) } for i = 1, START(N) do apply(label, { - AnchorPoint = Vector2.new(i, i) + AnchorPoint = vector.create(i, i, i) }) end end) - ROOT_BENCH("set aggregate mock vector2", function() + ROOT_BENCH("set aggregate vector", function() local apply = require "../src/apply" - local Vector2 = require "../test/mock".Vector2 local label = create "TextLabel" { - AnchorPoint = Vector2.new(1, 1) + AnchorPoint = vector.create(1, 1, 1) } for i = 1, START(N) do apply(label, { - AnchorPoint = { i, i } + AnchorPoint = { i, i, i } }) end end) end --- innacurate due to no Vector3 in vanilla Luau --- mock vector is 200x slower than native vector - --- ROOT_BENCH("spring update", function() --- local root, source, spring = vide.root, vide.source, vide.spring - --- local src = source(0) - --- root(function() --- for i = 1, N do --- spring(src) --- end - --- START(N) - --- src(1) - --- return nil --- end) --- end) - --- N /= 1024 - --- ROOT_BENCH("spring step", function() --- local root, source, spring = vide.root, vide.source, vide.spring - --- local src = source(0) - --- root(function() --- for i = 1, N do --- spring(src) --- end - --- src(1) - --- START(N) - --- vide.step(1/60) - --- return nil --- end) --- end) - return nil diff --git a/test/mock.luau b/test/mock.luau index 1dfb9bc..34564e6 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -257,40 +257,6 @@ local Vector2 = { __type = "Vector2" } :: any do end end -local Vector3 = { __type = "Vector3" } :: any do - local function new(x, y, z) - return setmetatable({ X = x, Y = y, Z = z }, Vector3) - end - - function Vector3.new(x, y, z) - return new(x or 0, y or 0, z or 0) - end - - function Vector3.__add(a, b) - return new(a.X + b.X, a.Y + b.Y, a.Z + b.Z) - end - - function Vector3.__sub(a, b) - return new(a.X - b.X, a.Y - b.Y, a.Z - b.Z) - end - - function Vector3.__mul(a, b) - return new(a.X * b, a.Y * b, a.Z * b) - end - - function Vector3.__unm(v) - return new(-v.X, -v.Y, -v.Z) - end - - function Vector3.__eq(a, b) - return a.X == b.X and a.Y == b.Y - end - - function Vector3.__index(v) - return (v.X^2 + v.Y^2 + v.Z^2)^0.5 - end -end - local UDim2 = { __type = "UDim2" } :: any do function UDim2.new(sx, ox, sy, oy) return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2)) @@ -330,7 +296,6 @@ return { Instance = Instance :: typeof(Instance), Color3 = Color3 :: typeof(Color3), Vector2 = Vector2 :: typeof(Vector2), - Vector3 = Vector3 :: typeof(Vector3), UDim2 = UDim2 :: typeof(UDim2), Enum = Enum :: typeof(Enum), typeof = typeof :: typeof(typeof) From f7e996191138d14fbdbd413b7fbdb95f22fe332a Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 16 Nov 2024 18:38:23 +0000 Subject: [PATCH 48/94] Fix library require --- init.luau | 8 ++++ src/init.luau | 121 +++----------------------------------------------- src/lib.luau | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 115 deletions(-) create mode 100644 init.luau create mode 100644 src/lib.luau diff --git a/init.luau b/init.luau new file mode 100644 index 0000000..5ef336f --- /dev/null +++ b/init.luau @@ -0,0 +1,8 @@ +local vide = require "./src/lib" + +export type source = vide.source +export type Source = vide.Source +export type context = vide.context +export type Context = vide.Context + +return vide diff --git a/src/init.luau b/src/init.luau index 79cbf75..11d1bd2 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,119 +1,10 @@ --------------------------------------------------------------------------------- --- vide.luau --------------------------------------------------------------------------------- +assert(game, "when using vide outside of Roblox, require lib.luau instead") -local version = { major = 0, minor = 3, patch = 1 } +local vide = require(script.lib) -local root = require "./root" -local mount = require "./mount" -local create = require "./create" -local apply = require "./apply" -local source = require "./source" -local effect = require "./effect" -local derive = require "./derive" -local cleanup = require "./cleanup" -local untrack = require "./untrack" -local read = require "./read" -local batch = require "./batch" -local context = require "./context" -local switch = require "./switch" -local show = require "./show" -local indexes, values = require "./maps"() -local spring, update_springs = require "./spring"() -local action = require "./action"() -local changed = require "./changed" -local throw = require "./throw" -local flags = require "./flags" - -export type Source = source.Source -export type source = Source -export type Context = context.Context -export type context = Context - -local function step(dt: number) - if game then - debug.profilebegin("VIDE STEP") - debug.profilebegin("VIDE SPRING") - end - - update_springs(dt) - - if game then - debug.profileend() - debug.profileend() - end -end - -local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) - task.defer(step, dt) -end) - -local vide = { - version = version, - - -- core - root = root, - mount = mount, - create = create, - source = source, - effect = effect, - derive = derive, - switch = switch, - show = show, - indexes = indexes, - values = values, - - -- util - cleanup = cleanup, - untrack = untrack, - read = read, - batch = batch, - context = context, - - -- animations - spring = spring, - - -- actions - action = action, - changed = changed, - - -- flags - strict = (nil :: any) :: boolean, - - -- temporary - apply = function(instance: Instance) - return function(props: { [any]: any }) - apply(instance, props) - return instance - end - end, - - -- runtime - step = function(dt: number) - if stepped then - stepped:Disconnect() - stepped = nil - end - step(dt) - end -} - -setmetatable(vide :: any, { - __index = function(_, index: unknown): () - if index == "strict" then - return flags.strict - else - throw(`{tostring(index)} is not a valid member of vide`) - end - end, - - __newindex = function(_, index: unknown, value: unknown) - if index == "strict" then - flags.strict = value :: boolean - else - throw(`{tostring(index)} is not a valid member of vide`) - end - end -}) +export type source = vide.source +export type Source = vide.Source +export type context = vide.context +export type Context = vide.Context return vide diff --git a/src/lib.luau b/src/lib.luau new file mode 100644 index 0000000..d3e526f --- /dev/null +++ b/src/lib.luau @@ -0,0 +1,115 @@ +local version = { major = 0, minor = 3, patch = 1 } + +local root = require "./root" +local mount = require "./mount" +local create = require "./create" +local apply = require "./apply" +local source = require "./source" +local effect = require "./effect" +local derive = require "./derive" +local cleanup = require "./cleanup" +local untrack = require "./untrack" +local read = require "./read" +local batch = require "./batch" +local context = require "./context" +local switch = require "./switch" +local show = require "./show" +local indexes, values = require "./maps"() +local spring, update_springs = require "./spring"() +local action = require "./action"() +local changed = require "./changed" +local throw = require "./throw" +local flags = require "./flags" + +export type Source = source.Source +export type source = Source +export type Context = context.Context +export type context = Context + +local function step(dt: number) + if game then + debug.profilebegin("VIDE STEP") + debug.profilebegin("VIDE SPRING") + end + + update_springs(dt) + + if game then + debug.profileend() + debug.profileend() + end +end + +local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) + task.defer(step, dt) +end) + +local vide = { + version = version, + + -- core + root = root, + mount = mount, + create = create, + source = source, + effect = effect, + derive = derive, + switch = switch, + show = show, + indexes = indexes, + values = values, + + -- util + cleanup = cleanup, + untrack = untrack, + read = read, + batch = batch, + context = context, + + -- animations + spring = spring, + + -- actions + action = action, + changed = changed, + + -- flags + strict = (nil :: any) :: boolean, + + -- temporary + apply = function(instance: Instance) + return function(props: { [any]: any }) + apply(instance, props) + return instance + end + end, + + -- runtime + step = function(dt: number) + if stepped then + stepped:Disconnect() + stepped = nil + end + step(dt) + end +} + +setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if index == "strict" then + return flags.strict + else + throw(`{tostring(index)} is not a valid member of vide`) + end + end, + + __newindex = function(_, index: unknown, value: unknown) + if index == "strict" then + flags.strict = value :: boolean + else + throw(`{tostring(index)} is not a valid member of vide`) + end + end +}) + +return vide From fdb4a137a841ddaa38ddb660d0e96bc2c9c412f7 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 16 Nov 2024 18:41:27 +0000 Subject: [PATCH 49/94] Fix test require --- src/context.luau | 2 -- src/create.luau | 1 - test/benchmark.luau | 2 +- test/relative-string.luau | 9 --------- test/spring-test.luau | 2 +- test/tests.luau | 6 +++--- 6 files changed, 5 insertions(+), 17 deletions(-) delete mode 100644 test/relative-string.luau diff --git a/src/context.luau b/src/context.luau index 882b838..a46ad5a 100644 --- a/src/context.luau +++ b/src/context.luau @@ -1,5 +1,3 @@ -if not game then script = require "../test/relative-string" end - local throw = require "./throw" local graph = require "./graph" type Node = graph.Node diff --git a/src/create.luau b/src/create.luau index 5b6c9df..2711511 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,4 +1,3 @@ -if not game then script = require "../test/relative-string" end local typeof = game and typeof or require "../test/mock".typeof :: never local Instance = game and Instance or require "../test/mock".Instance :: never diff --git a/test/benchmark.luau b/test/benchmark.luau index 0177e58..524cea7 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -1,7 +1,7 @@ local testkit = require("./testkit") local BENCH, START = testkit.benchmark() -local vide = require "../src/init" +local vide = require "../../vide" local source = vide.source local derive = vide.derive local effect = vide.effect diff --git a/test/relative-string.luau b/test/relative-string.luau deleted file mode 100644 index 232215c..0000000 --- a/test/relative-string.luau +++ /dev/null @@ -1,9 +0,0 @@ -local function dir(directory: string) - return setmetatable({} :: { [string]: any }, - { __index = function(_, path) return directory .. path end }) -end - -local script = dir "src/" -script.Parent = dir "src/" - -return script diff --git a/test/spring-test.luau b/test/spring-test.luau index a849598..17175f5 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -1,4 +1,4 @@ -local vide = require "../src/init" +local vide = require "../../vide" local testkit = require("../test/testkit") local program_time = os.clock() diff --git a/test/tests.luau b/test/tests.luau index 42f9563..25e5157 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -5,8 +5,8 @@ local mock = require "./mock" local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 -local vide = require "../src/init" -local graph = require "../src/graph" +local vide = require "../../vide" +local graph = require "../../vide/src/graph" type Node = graph.Node type Map = { [K] : V } @@ -2260,7 +2260,7 @@ TEST("context()", function() end) TEST("nested effects cases", function() - local vide = require "../src/init" + local vide = require "../../vide" local source = vide.source local effect = vide.effect local untrack = vide.untrack From 8799988851e9f672c476479508b88c60bbd408b7 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 16 Nov 2024 18:55:37 +0000 Subject: [PATCH 50/94] Update github workflow Luau version --- .github/workflows/unit-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 4a9db7f..69bed32 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -14,7 +14,7 @@ jobs: uses: robinraju/release-downloader@v1.6 with: repository: Roblox/luau - tag: "0.620" + tag: "0.651" fileName: luau-ubuntu.zip out-file-path: bin From 44fe65ee5e140c92cac0476e08031bfd697bdf61 Mon Sep 17 00:00:00 2001 From: 11poe <404johnnydoe@gmail.com> Date: Sat, 16 Nov 2024 21:20:49 +0100 Subject: [PATCH 51/94] Add create(a, { props }) syntax (#42) * Add new create syntax * Add new create syntax to changelog * Fix new create syntax test --- CHANGELOG.md | 8 +++++ src/create.luau | 89 +++++++++++++++++++++++++++---------------------- test/tests.luau | 13 ++++++++ 3 files changed, 70 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e7087..617ae1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- +## [Unreleased] + +### Added + +- `create("ClassName", { props })` and `create(Instance, { props })` syntax. + +-------------------------------------------------------------------------------- + ## [0.3.1] - 2024-10-09 ### Added diff --git a/src/create.luau b/src/create.luau index 2711511..dea8ca7 100644 --- a/src/create.luau +++ b/src/create.luau @@ -20,8 +20,8 @@ setmetatable(ctor_cache :: any, { end local function ctor(properties: Props): Instance - return apply(instance:Clone(), properties) - end + return apply(instance:Clone(), properties) + end self[class] = ctor return ctor @@ -40,44 +40,53 @@ local function clone_instance(instance: Instance) end end -local function create(class_or_instance: string|Instance): (Props) -> Instance - if type(class_or_instance) == "string" then - return create_instance(class_or_instance) - elseif typeof(class_or_instance) == "Instance" then - return clone_instance(class_or_instance) - else - throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) - return nil :: never - end +local function create(class_or_instance: string | Instance, props: Props?): ((Props) -> Instance) | Instance + local result: (Props) -> Instance + if type(class_or_instance) == "string" then + result = create_instance(class_or_instance) + elseif typeof(class_or_instance) == "Instance" then + result = clone_instance(class_or_instance) + else + throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) + return nil :: never + end + if props then + return result(props) + end + return result end type Props = { [any]: any } -return (create :: any) :: -( (T & Instance) -> (Props) -> T ) & -( ("Folder") -> (Props) -> Folder ) & -( ("BillboardGui") -> (Props) -> BillboardGui ) & -( ("CanvasGroup") -> (Props) -> CanvasGroup ) & -( ("Frame") -> (Props) -> Frame ) & -( ("ImageButton") -> (Props) -> ImageButton ) & -( ("ImageLabel") -> (Props) -> ImageLabel ) & -( ("ScreenGui") -> (Props) -> ScreenGui ) & -( ("ScrollingFrame") -> (Props) -> ScrollingFrame ) & -( ("SurfaceGui") -> (Props) -> SurfaceGui ) & -( ("TextBox") -> (Props) -> TextBox ) & -( ("TextButton") -> (Props) -> TextButton ) & -( ("TextLabel") -> (Props) -> TextLabel ) & -( ("UIAspectRatioConstraint") -> (Props) -> UIAspectRatioConstraint ) & -( ("UICorner") -> (Props) -> UICorner ) & -( ("UIGradient") -> (Props) -> UIGradient ) & -( ("UIGridLayout") -> (Props) -> UIGridLayout ) & -( ("UIListLayout") -> (Props) -> UIListLayout ) & -( ("UIPadding") -> (Props) -> UIPadding ) & -( ("UIPageLayout") -> (Props) -> UIPageLayout ) & -( ("UIScale") -> (Props) -> UIScale ) & -( ("UISizeConstraint") -> (Props) -> UISizeConstraint ) & -( ("UIStroke") -> (Props) -> UIStroke ) & -( ("UITableLayout") -> (Props) -> UITableLayout ) & -( ("UITextSizeConstraint") -> (Props) -> UITextSizeConstraint ) & -( ("VideoFrame") -> (Props) -> VideoFrame ) & -( ("ViewportFrame") -> (Props) -> ViewportFrame ) & -( (string) -> (Props) -> Instance ) + +type Create = ((Name, Props) -> Instance) & ((Name) -> (Props) -> Instance) + +return (create :: any) :: + & ( (T & Instance) -> (Props) -> T ) + & ( (T & Instance, Props) -> T ) + & Create<"Folder", Folder> + & Create<"BillboardGui", BillboardGui> + & Create<"CanvasGroup", CanvasGroup> + & Create<"Frame", Frame> + & Create<"ImageButton", ImageButton> + & Create<"ImageLabel", ImageLabel> + & Create<"ScreenGui", ScreenGui> + & Create<"ScrollingFrame", ScrollingFrame> + & Create<"SurfaceGui", SurfaceGui> + & Create<"TextBox", TextBox> + & Create<"TextButton", TextButton> + & Create<"TextLabel", TextLabel> + & Create<"UIAspectRatioConstraint", UIAspectRatioConstraint> + & Create<"UICorner", UICorner> + & Create<"UIGradient", UIGradient> + & Create<"UIGridLayout", UIGridLayout> + & Create<"UIListLayout", UIListLayout> + & Create<"UIPadding", UIPadding> + & Create<"UIPageLayout", UIPageLayout> + & Create<"UIScale", UIScale> + & Create<"UISizeConstraint", UISizeConstraint> + & Create<"UIStroke", UIStroke> + & Create<"UITableLayout", UITableLayout> + & Create<"UITextSizeConstraint", UITextSizeConstraint> + & Create<"VideoFrame", VideoFrame> + & Create<"ViewportFrame", ViewportFrame> + & Create diff --git a/test/tests.luau b/test/tests.luau index 25e5157..8f30a82 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -721,6 +721,19 @@ TEST("create()", wrap_root(function() local source = vide.source local cleanup = vide.cleanup + do CASE "create(\"ClassName\", props) syntax" + local frame = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) + CHECK(frame.BackgroundTransparency == 0.5) + CHECK(frame.Name == "Foo") + end + + do CASE "create(Instance, props) syntax" + local frame0 = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) + local frame = create(frame0, { BackgroundTransparency = 1 }) + CHECK(frame.BackgroundTransparency == 1) + CHECK(frame.Name == "Foo") + end + do CASE "apply default properties" local defaults = require "../src/defaults" local frame = create "Frame" {} :: Instance & { BorderSizePixel: any, BorderColor3: any } From e60aa57ca260f1a8899f1e15c590ec79d44f9fd8 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Wed, 27 Nov 2024 19:43:51 +0000 Subject: [PATCH 52/94] Fix edge case with map functions --- src/maps.luau | 4 +-- test/tests.luau | 77 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/maps.luau b/src/maps.luau index 5fea836..bd59a6c 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -61,6 +61,8 @@ local function indexes(input: () -> Map, transform: (() -> VI, local cv = input_cache[i] if cv ~= v then + input_cache[i] = v + if cv == nil then -- create new scope and run transform local scope = create_node(subowner, false, false) scopes[i] = scope :: Node @@ -87,8 +89,6 @@ local function indexes(input: () -> Map, transform: (() -> VI, input_nodes[i].cache = v update_descendants(input_nodes[i]) end - - input_cache[i] = v end end diff --git a/test/tests.luau b/test/tests.luau index 8f30a82..a0ba2ff 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2646,10 +2646,12 @@ TEST("strict", wrap_root(function() do CASE "destruction of active scope" local src = source(false) + local count = 0 root(function() show(src, function() src(false) + vide.cleanup(function() count += 1 end) return {} end) end) @@ -2661,38 +2663,59 @@ TEST("strict", wrap_root(function() CHECK(not ok) end - -- todo: review intended behavior here - -- do CASE "destruction of active scope in indexes" - -- local src = source {} + do CASE "destruction of active scope in indexes" + local src = source {} - -- local tmp - -- root(function() - -- effect(function() - -- untrack(function() - -- tmp = indexes(src, function() - -- vide.cleanup(function() print "test" end) - -- src {} - -- print "updated" - -- vide.cleanup(function() print "test2" end) - -- print "end" - -- return {} - -- end) - -- return nil - -- end) - -- end) - -- end) + local count_1 = 0 + local count_2 = 0 - -- print "setting" + root(function() + effect(function() + untrack(function() + indexes(src, function() + vide.cleanup(function() count_1 += 1 end) + src {} + vide.cleanup(function() count_2 += 1 end) + return {} + end) + return nil + end) + end) + end) - -- local ok = pcall(function() - -- src { 1 } - -- print "done" - -- end) + local ok = pcall(function() + src { 1 } + end) - -- print(#tmp()) + CHECK(not ok) + end - -- CHECK(not ok) - -- end + do CASE "destruction of active scope in values" + local src = source {} + + local count_1 = 0 + local count_2 = 0 + + root(function() + effect(function() + untrack(function() + values(src, function() + vide.cleanup(function() count_1 += 1 end) + src {} + vide.cleanup(function() count_2 += 1 end) + return {} + end) + return nil + end) + end) + end) + + local ok = pcall(function() + src { {} } + end) + + CHECK(not ok) + end end)) local ok = FINISH() From caa9eaf733b1c6e889fe6c71755dafcace8492da Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 28 Nov 2024 01:00:11 +0000 Subject: [PATCH 53/94] Add thread cleanup helper --- CHANGELOG.md | 6 ++++++ docs/api/reactivity-utility.md | 2 +- src/cleanup.luau | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 617ae1f..ca9bbb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - `create("ClassName", { props })` and `create(Instance, { props })` syntax. +- `cleanup()` now accepts `thread` types. + +### Changed + +- A scope can no longer be destroyed while it is active. Strict mode will check + for this. -------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index d084976..b8bff4d 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -7,7 +7,7 @@ Queues a callback to run when a scope is reran or destroyed. - **Type** ```luau - function cleanup(v: Function | Disconnectable | Destroyable) + function cleanup(v: Function | Disconnectable | Destroyable | thread) type Function = () -> () type Destroyable = { destroy: () -> () } diff --git a/src/cleanup.luau b/src/cleanup.luau index 54195fb..20d188b 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -8,6 +8,7 @@ local push_cleanup = graph.push_cleanup local function helper(obj: any) return if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end + elseif type(obj) == "thread" then function() task.cancel(obj) end elseif typeof(obj) == "Instance" then function() obj:Destroy() end elseif obj.destroy then function() obj:destroy() end elseif obj.disconnect then function() obj:disconnect() end @@ -35,6 +36,7 @@ type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } return cleanup :: ( (callback: () -> ()) -> () ) & + ( (thread: thread) -> () ) & ( (instance: Destroyable) -> () ) & ( (connection: Disconnectable) -> () ) & ( (instance: Instance) -> () ) & From 3b8d9098c0cc710f6facace8ccb8d4cb44c063cc Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:00:37 -0800 Subject: [PATCH 54/94] Use `vector.max` to check spring activity (#44) * Use `vector.max` to check spring activity * Revert unnecessary changes --- src/spring.luau | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index acb50ac..bcd5789 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -30,11 +30,12 @@ local update_descendants = graph.update_descendants local push_child_to_scope = graph.push_child_to_scope local UPDATE_RATE = 120 -local TOLERANCE = 0.0001 +local TOLERANCE = 0.001 +local TOLERANCE_VECTOR = vector.create(TOLERANCE, TOLERANCE, TOLERANCE) type Vec3 = Vector3 -local function Vec3(x: number?, y: number?, z: number?): Vec3 +local function Vec3(x: number, y: number, z: number): Vec3 return vector.create(x, y, z) end @@ -268,13 +269,16 @@ local function update_spring_sources() x0_456, x1_456, v_456 = data.x0_123, data.x1_123, data.v_123, data.x0_456, data.x1_456, data.v_456 - - local dx_123, dx_456 = - x0_123 - x1_123, - x0_456 - x1_456 - -- todo: can this false positive? - if vector.magnitude(v_123 + v_456 + dx_123 + dx_456) < TOLERANCE then + local max_difference = vector.max( + vector.abs(x0_123 - x1_123 :: any), + vector.abs(x0_456 - x1_456 :: any), + vector.abs(v_123 :: any), + vector.abs(v_456 :: any), + TOLERANCE_VECTOR + ) + + if max_difference == TOLERANCE_VECTOR then -- close enough to target, unshedule spring and set value to target table.insert(remove_queue, data) output.cache = data.source_value From ccaeb030f32283d3dd189c007524259f72d8a4cc Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 26 Dec 2024 21:17:14 +0000 Subject: [PATCH 55/94] Remove aggregate construction --- CHANGELOG.md | 4 ++++ src/apply.luau | 24 +----------------------- test/benchmark.luau | 34 ---------------------------------- test/tests.luau | 15 --------------- 4 files changed, 5 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9bbb8..83b4399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - A scope can no longer be destroyed while it is active. Strict mode will check for this. +### Removed + +- Aggregate construction when setting properties with `create()`. + -------------------------------------------------------------------------------- ## [0.3.1] - 2024-10-09 diff --git a/src/apply.luau b/src/apply.luau index 1598f3b..15246a6 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -62,22 +62,6 @@ local function return_caches(caches: typeof(free_caches) ) free_caches = caches end --- map of datatype names to class default constructor for aggregate init -local aggregates = {} -for name, class in { - CFrame = CFrame, - Color3 = Color3, - UDim = UDim, - UDim2 = UDim2, - Vector2 = Vector2, - Rect = Rect -} :: Map do - aggregates[name] = class.new -end - -aggregates.Vector3 = vector.create -aggregates.vector = vector.create - -- applies table of nested properties to an instance using full vide semantics local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T if not properties then @@ -109,13 +93,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown nested_debug[depth][property] = true end - if type(value) == "table" then -- attempt aggregate init - local ctor = aggregates[typeof((instance :: any)[property])] - if ctor == nil then - throw(`cannot aggregate type {typeof(value)} for property {property}`) - end - (instance :: any)[property] = ctor(unpack(value :: {})) - elseif type(value) == "function" then + if type(value) == "function" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then events[property] = value :: () -> () -- add event to buffer else diff --git a/test/benchmark.luau b/test/benchmark.luau index 524cea7..690c8b9 100644 --- a/test/benchmark.luau +++ b/test/benchmark.luau @@ -544,38 +544,4 @@ ROOT_BENCH("spring step", function() end) end) -TITLE "aggregate" - -do - -- the purpose of the two following benchmarks is to measure the overhead of - -- aggregate construction - ROOT_BENCH("set explicit vector", function() - local apply = require "../src/apply" - - local label = create "TextLabel" { - AnchorPoint = vector.create(1, 1, 1) - } - - for i = 1, START(N) do - apply(label, { - AnchorPoint = vector.create(i, i, i) - }) - end - end) - - ROOT_BENCH("set aggregate vector", function() - local apply = require "../src/apply" - - local label = create "TextLabel" { - AnchorPoint = vector.create(1, 1, 1) - } - - for i = 1, START(N) do - apply(label, { - AnchorPoint = { i, i, i } - }) - end - end) -end - return nil diff --git a/test/tests.luau b/test/tests.luau index a0ba2ff..85f5357 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -759,21 +759,6 @@ TEST("create()", wrap_root(function() CHECK(text.Text == "test") end - do CASE "aggregate construction" - local template = create "TextLabel" { - AnchorPoint = Vector2.new(), - Position = UDim2.new() - } - - local text = create(template) { - AnchorPoint = { 1, 2 }, - Position = { 3, 4 } - } - - CHECK(text.AnchorPoint == Vector2.new(1, 2)) - CHECK(text.Position == UDim2.new(3, 4)) - end - do CASE "nested precedence" local text = create "TextLabel" { { From 4c639f838881798cd775c5932628cb025457adce Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 26 Dec 2024 22:36:58 +0000 Subject: [PATCH 56/94] Add flag to disable deferral of nested properties --- src/apply.luau | 121 +++++++++++++++++++++++++----------------------- src/flags.luau | 6 ++- src/lib.luau | 13 +++--- test/tests.luau | 23 +++++++-- 4 files changed, 95 insertions(+), 68 deletions(-) diff --git a/src/apply.luau b/src/apply.luau index 15246a6..754c0d2 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -13,11 +13,11 @@ type Array = { V } type ArrayOrV = {ArrayOrV} | V type Map = { [K]: V } -local free_caches: { +type Cache = { -- event listeners to connect after properties are set - events: Map< - string, -- event name - () -> () -- listener + events: Array< + | string -- 1. event name + | () -> () -- 2. listener >, -- actions to run after events are connected @@ -32,18 +32,18 @@ local free_caches: { Map -- set of property names >, - -- use stack instead of recursive function to process nesting 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 } nested_stack: { {} | number } -}? +} -local function borrow_caches(): typeof(assert(free_caches)) - if free_caches then - local caches = free_caches :: typeof(assert(free_caches)) - free_caches = nil - return caches +local free_cache: Cache? + +local function borrow_cache(): Cache + if free_cache then + local cache = free_cache + free_cache = nil + return cache else return { events = {}, @@ -58,8 +58,49 @@ local function borrow_caches(): typeof(assert(free_caches)) end end -local function return_caches(caches: typeof(free_caches) ) - free_caches = caches +local function return_cache(cache: Cache ) + free_cache = cache +end + +local function process_properties(properties: Map, instance: Instance, cache: Cache, depth: number) + for property, value in properties do + if property == "Parent" then continue end + + if type(property) == "string" then + if flags.strict then -- check for duplicate property assignment at nesting depth + if cache.nested_debug[depth][property] then + throw(`duplicate property {property} at depth {depth}`) + end + cache.nested_debug[depth][property] = true + end + + if type(value) == "function" then + if typeof((instance :: any)[property]) == "RBXScriptSignal" then + table.insert(cache.events, property) -- add event name to buffer + table.insert(cache.events, value :: () -> ()) -- add event listener to buffer + else + bind.property(instance, property, value :: () -> ()) -- create implicit effect for property + end + else + (instance :: any)[property] = value -- set property + end + elseif type(property) == "number" then + if type(value) == "function" then + bind.children(instance, value :: () -> ArrayOrV) -- bind children + elseif type(value) == "table" then + if is_action(value) then + table.insert(cache.actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + elseif flags.defer_nested_properties then + table.insert(cache.nested_stack, value :: {}) + table.insert(cache.nested_stack, depth + 1) -- push table to stack for later processing + else + process_properties(value :: Map, instance, cache, depth + 1) + end + else + (value :: Instance).Parent = instance -- parent child + end + end + end end -- applies table of nested properties to an instance using full vide semantics @@ -68,12 +109,10 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown throw("attempt to call a constructor returned by create() with no properties") end - local strict = flags.strict - -- queue parent assignment if any for last local parent: unknown = properties.Parent - local caches = borrow_caches() + local caches = borrow_cache() local events = caches.events local actions = caches.actions local nested_debug = caches.nested_debug @@ -82,49 +121,15 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown -- process all properties local depth = 1 repeat - for property, value in properties do - if property == "Parent" then continue end - - if type(property) == "string" then - if strict then -- check for duplicate prop assignment at nesting depth - if nested_debug[depth][property] then - throw(`duplicate property {property} at depth {depth}`) - end - nested_debug[depth][property] = true - end - - if type(value) == "function" then - if typeof((instance :: any)[property]) == "RBXScriptSignal" then - events[property] = value :: () -> () -- add event to buffer - else - 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 :: () -> ArrayOrV) -- bind children - elseif type(value) == "table" then - if is_action(value) then - table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer - else - 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 - end - end - end - + process_properties(properties, instance, caches, depth) depth = table.remove(nested_stack) :: number properties = table.remove(nested_stack) :: {} - until not properties - for event, listener in next, events do - (instance :: any)[event]:Connect(listener) + for i = 1, #events, 2 do + local event_name = events[i] + local event_listener = events[i + 1] + ;(instance :: any)[event_name]:Connect(event_listener) end for _, queued in next, actions do @@ -145,10 +150,10 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown -- clear caches table.clear(events) for _, queued in next, actions do table.clear(queued) end - if strict then table.clear(nested_debug) end + if flags.strict then table.clear(nested_debug) end table.clear(nested_stack) - return_caches(caches) + return_cache(caches) return instance end diff --git a/src/flags.luau b/src/flags.luau index cc2d2f8..bc9424c 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -4,4 +4,8 @@ end local is_O2 = inline_test() ~= "inline_test" -return { strict = not is_O2, batch = false } +return { + strict = not is_O2, + batch = false, + defer_nested_properties = true +} diff --git a/src/lib.luau b/src/lib.luau index d3e526f..a54bd32 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -75,6 +75,7 @@ local vide = { -- flags strict = (nil :: any) :: boolean, + defer_nested_properties = (nil :: any) :: boolean, -- temporary apply = function(instance: Instance) @@ -96,18 +97,18 @@ local vide = { setmetatable(vide :: any, { __index = function(_, index: unknown): () - if index == "strict" then - return flags.strict - else + if flags[index] == nil then throw(`{tostring(index)} is not a valid member of vide`) + else + return flags[index] end end, __newindex = function(_, index: unknown, value: unknown) - if index == "strict" then - flags.strict = value :: boolean - else + if flags[index] == nil then throw(`{tostring(index)} is not a valid member of vide`) + else + flags[index] = value end end }) diff --git a/test/tests.luau b/test/tests.luau index 85f5357..b11e47a 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -759,18 +759,35 @@ TEST("create()", wrap_root(function() CHECK(text.Text == "test") end - do CASE "nested precedence" + do CASE "nested deferred" local text = create "TextLabel" { { + { Text = "2" }, Text = "1", - - { Text = "2" } } } CHECK(text.Text == "2") end + do CASE "nested not deferred" + vide.defer_nested_properties = false + + local t = {} + + create "TextLabel" { + { + { function() table.insert(t, 1) end } :: any, + function() table.insert(t, 2) end, + } + } + + CHECK(t[1] == 1) + CHECK(t[2] == 2) + + vide.defer_nested_properties = true + end + do CASE "independent" local frame = create "Frame" CHECK(frame {} ~= frame {}) From b44b9ef2baa0de0138567fa2ea9cb7460a3f7fdf Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Fri, 27 Dec 2024 01:00:45 +0000 Subject: [PATCH 57/94] Add implicit effects recursively creating more implicit effects for children --- CHANGELOG.md | 4 ++ docs/api/strict-mode.md | 2 +- src/apply.luau | 12 ++-- src/bind.luau | 103 --------------------------------- src/implicit_effect.luau | 119 +++++++++++++++++++++++++++++++++++++++ test/tests.luau | 42 ++++++++++++++ 6 files changed, 170 insertions(+), 112 deletions(-) delete mode 100644 src/bind.luau create mode 100644 src/implicit_effect.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b4399..61b8fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `create("ClassName", { props })` and `create(Instance, { props })` syntax. - `cleanup()` now accepts `thread` types. +- Implicit effects to set children can now recursively create more implicit + effects to set children. ### Changed - A scope can no longer be destroyed while it is active. Strict mode will check for this. +- Implicit effects to set children now unparent all children when the effect is + destroyed. ### Removed diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 630a7bd..6f9ee80 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -20,7 +20,7 @@ Currently, strict mode will: 4. Checks for `values()` input having duplicate values. 5. Checks for duplicate nested properties at same depth. 6. Checks for destruction of an active scope. -7. Better error reporting and stack traces + creation traces of property bindings. +7. Better error reporting and stack traces. By rerunning reactive scopes twice each time they update, it helps ensure that computations are pure, and that any cleanup is done correctly. diff --git a/src/apply.luau b/src/apply.luau index 754c0d2..2b30ba6 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,10 +1,8 @@ local typeof = game and typeof or require "../test/mock".typeof :: never -local Vector2 = game and Vector2 or require "../test/mock".Vector2 :: never -local UDim2 = game and UDim2 or require "../test/mock".UDim2 :: never local flags = require "./flags" local throw = require "./throw" -local bind = require "./bind" +local implicit_effect = require "./implicit_effect" local _, is_action = require "./action"() local graph = require "./graph" type Node = graph.Node @@ -79,14 +77,14 @@ local function process_properties(properties: Map, instance: I table.insert(cache.events, property) -- add event name to buffer table.insert(cache.events, value :: () -> ()) -- add event listener to buffer else - bind.property(instance, property, value :: () -> ()) -- create implicit effect for property + implicit_effect.property(instance, property, value :: () -> ()) -- create implicit effect for property end else (instance :: any)[property] = value -- set property end elseif type(property) == "number" then if type(value) == "function" then - bind.children(instance, value :: () -> ArrayOrV) -- bind children + implicit_effect.children(instance, value :: () -> ArrayOrV) -- bind children elseif type(value) == "table" then if is_action(value) then table.insert(cache.actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer @@ -138,16 +136,14 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end end - -- finally set parent if any if parent then if type(parent) == "function" then - bind.parent(instance, parent :: () -> Instance) + implicit_effect.parent(instance, parent :: () -> Instance) else instance.Parent = parent :: Instance end end - -- clear caches table.clear(events) for _, queued in next, actions do table.clear(queued) end if flags.strict then table.clear(nested_debug) end diff --git a/src/bind.luau b/src/bind.luau deleted file mode 100644 index ff32bcb..0000000 --- a/src/bind.luau +++ /dev/null @@ -1,103 +0,0 @@ -local graph = require "./graph" -type Node = graph.Node -local create_node = graph.create_node -local assert_stable_scope = graph.assert_stable_scope -local evaluate_node = graph.evaluate_node - -function create_implicit_effect(updater: (T) -> T, binding: T) - evaluate_node(create_node(assert_stable_scope(), updater, binding)) -end - -type PropertyBinding = { - instance: Instance, - property: string, - source: () -> unknown -} - -local function update_property_effect(p: PropertyBinding) - (p.instance :: any)[p.property] = p.source() - return p -end - -type ParentBinding = { - instance: Instance, - parent: () -> Instance -} - -local function update_parent_effect(p: ParentBinding) - p.instance.Parent = p.parent() - return p -end - -type ChildrenBinding = { - instance: Instance, - cur_children_set: { [Instance]: true }, - new_children_set: { [Instance]: true }, - children: () -> Instance | { Instance } -} - -type ArrayOrV = V | { V } -local function update_children_effect(p: ChildrenBinding) - local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update - local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update - - local new_children = p.children() -- all (and only) children that should be parented after this update - - if type(new_children) ~= "table" then - new_children = { new_children } - end - - local function process_child(child: ArrayOrV) - if type(child) == "table" then - for _, child in next, child do - process_child(child) - end - else - if new_child_set[child] then return end -- stops redundant reparenting - - 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 - else - cur_children_set[child] = nil -- remove child from cache if it was already in cache - end - end - end - - process_child(new_children) - - for child in next, cur_children_set do - child.Parent = nil -- unparent all children that weren't in the new children set - end - - table.clear(cur_children_set) -- clear cache, preserve capacity - p.cur_children_set, p.new_children_set = new_child_set, cur_children_set - - return p -end - -return { - property = function(instance, property, source) - return create_implicit_effect(update_property_effect, { - instance = instance, - property = property, - source = source - }) - end, - - parent = function(instance, parent) - return create_implicit_effect(update_parent_effect, { - instance = instance, - parent = parent - }) - end, - - children = function(instance, children) - return create_implicit_effect(update_children_effect, { - instance = instance, - cur_children_set = {}, - new_children_set = {}, - children = children - }) - end -} diff --git a/src/implicit_effect.luau b/src/implicit_effect.luau new file mode 100644 index 0000000..5332662 --- /dev/null +++ b/src/implicit_effect.luau @@ -0,0 +1,119 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local assert_stable_scope = graph.assert_stable_scope +local get_scope = graph.get_scope +local evaluate_node = graph.evaluate_node +local push_cleanup = graph.push_cleanup + +local function update_property_effect(p: { + instance: Instance, + property: string, + source: () -> unknown +}) + (p.instance :: any)[p.property] = p.source() + return p +end + +local function update_parent_effect(p: { + instance: Instance, + source: () -> Instance +}) + p.instance.Parent = p.source() + return p +end + +local function update_children_effect(p: { + instance: Instance, + cur_children_set: { [Instance]: true }, + new_children_set: { [Instance]: true }, + source: () -> Instance | { Instance } +}) + local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update + local new_children_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update + + local new_children = p.source() -- all (and only) children that should be parented after this update + + local function process_child(child: Instance | { Instance }) + if type(child) == "userdata" then + if new_children_set[child] then return end -- stops redundant reparenting + + new_children_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 + else + cur_children_set[child] = nil -- remove child from cache if it was already in cache + end + elseif type(child) == "table" then + for _, child in next, child do + process_child(child) + end + elseif type(child) == "function" then + local node = create_node(assert(get_scope()), update_children_effect, { + instance = p.instance, + cur_children_set = {}, + new_children_set = {}, + source = child + }) + + evaluate_node(node) + + push_cleanup(assert(get_scope()), function() + for child in node.cache.cur_children_set do + child.Parent = nil + end + end) + end + end + + process_child(new_children) + + for child in next, cur_children_set do + child.Parent = nil -- unparent all children that weren't in the new children set + end + + table.clear(cur_children_set) -- clear cache, preserve capacity + p.cur_children_set, p.new_children_set = new_children_set, cur_children_set + + return p +end + +return { + property = function(instance, property, source) + local node = create_node(assert_stable_scope(), update_property_effect, { + instance = instance, + property = property, + source = source + }) + evaluate_node(node) + return node + end, + + parent = function(instance, parent) + local node = create_node(assert_stable_scope(), update_parent_effect, { + instance = instance, + source = parent + }) + evaluate_node(node) + return node + end, + + children = function(instance, children) + local node = create_node(assert_stable_scope(), update_children_effect, { + instance = instance, + cur_children_set = {}, + new_children_set = {}, + source = children + }) + + evaluate_node(node) + + push_cleanup(assert_stable_scope(), function() + for child in node.cache.cur_children_set do + child.Parent = nil + end + end) + + return node + end +} diff --git a/test/tests.luau b/test/tests.luau index b11e47a..d4c95ae 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -996,6 +996,48 @@ TEST("create()", wrap_root(function() CHECK(not obj:FindFirstChild("e")) end + do CASE "nested children source effect" + local a = create "Frame" { Name = "a" } :: Instance + local b = create "Frame" { Name = "b" } :: Instance + local c = create "Frame" { Name = "c" } :: Instance + + local nested_children = source { b, c } + local children = source { a :: Instance | () -> { Instance }, nested_children } + + local parent = create "Frame" { + Name = "parent", + children + } + + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + nested_children {} + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + nested_children { b } + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + children { a } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + nested_children { b, c } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + children { a :: Instance | () -> { Instance }, nested_children } + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + nested_children { c } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + end + do CASE "garbage collection test" local wref From 3b22f6ccf937fa327819caf1006c73fcb1f47717 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Fri, 27 Dec 2024 02:34:31 +0000 Subject: [PATCH 58/94] Add spring setter --- CHANGELOG.md | 2 + docs/api/animation.md | 8 +- src/spring.luau | 136 +++++++++++------------ test/{benchmark.luau => benchmarks.luau} | 0 4 files changed, 75 insertions(+), 71 deletions(-) rename test/{benchmark.luau => benchmarks.luau} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b8fd7..7c6303b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `cleanup()` now accepts `thread` types. - Implicit effects to set children can now recursively create more implicit effects to set children. +- `spring()` returns a second value, a setter to set position, velocity and + impulse. ### Changed diff --git a/docs/api/animation.md b/docs/api/animation.md index 8b84ce9..8719753 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -11,9 +11,15 @@ Returns a new source with a value always moving torwards the input source value. source: () -> T & Animatable, period: number = 1, damping_ratio: number = 1 - ): () -> T + ): (() -> T, Setter) type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect + + type Setter = ({ + position: T?, + velocity: T?, + impulse: T? + }) -> () ``` - **Details** diff --git a/src/spring.luau b/src/spring.luau index bcd5789..053c945 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,23 +1,3 @@ ---[[ - -Supported datatypes: -- number -- CFrame -- Color3 -- UDim -- UDim2 -- Vector2 -- Vector3 -- Rect - -Unsupported datatypes: -- bool -- Vector2int16 -- Vector3int16 -- EnumItem - -]] - local throw = require "./throw" local graph = require "./graph" type Node = graph.Node @@ -33,68 +13,68 @@ local UPDATE_RATE = 120 local TOLERANCE = 0.001 local TOLERANCE_VECTOR = vector.create(TOLERANCE, TOLERANCE, TOLERANCE) -type Vec3 = Vector3 - -local function Vec3(x: number, y: number, z: number): Vec3 - return vector.create(x, y, z) -end - -local ZERO = Vec3(0, 0, 0) - type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 -type SpringData = { +--[[ +Unsupported datatypes: +- bool +- Vector2int16 +- Vector3int16 +- EnumItem +]] + +type SpringState = { k: number, -- spring constant c: number, -- damping coeff - -- dimensions 1-3 - x0_123: Vec3, - x1_123: Vec3, - v_123: Vec3, - - -- dimensions 4-6 - x0_456: Vec3, - x1_456: Vec3, - v_456: Vec3, + x0_123: vector, x0_456: vector, -- current position + x1_123: vector, x1_456: vector, -- target position + v_123: vector, v_456: vector, -- current velocity source_value: T -- current value of spring input source } -type TypeToVec6 = (T) -> (Vec3, Vec3) -type Vec6ToType = (Vec3, Vec3) -> T +type SpringSettings = ({ + position: T?, + velocity: T?, + impulse: T? +}) -> () + +type TypeToVec6 = (T) -> (vector, vector) +type Vec6ToType = (vector, vector) -> T local type_to_vec6 = { number = function(v) - return Vec3(v, 0, 0), ZERO + return vector.create(v, 0, 0), vector.zero end :: TypeToVec6, CFrame = function(v) - return v.Position, Vec3(v:ToEulerAnglesXYZ()) + return v.Position, vector.create(v:ToEulerAnglesXYZ()) end :: TypeToVec6, Color3 = function(v) -- todo: hsv, oklab? - return Vec3(v.R, v.G, v.B), ZERO + return vector.create(v.R, v.G, v.B), vector.zero end :: TypeToVec6, UDim = function(v) - return Vec3(v.Scale, v.Offset, 0), ZERO + return vector.create(v.Scale, v.Offset, 0), vector.zero end :: TypeToVec6, UDim2 = function(v) - return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) + return vector.create(v.X.Scale, v.X.Offset, v.Y.Scale), vector.create(v.Y.Offset, 0, 0) end :: TypeToVec6, Vector2 = function(v) - return Vec3(v.X, v.Y, 0), ZERO + return vector.create(v.X, v.Y, 0), vector.zero end :: TypeToVec6, Vector3 = function(v) - return v, ZERO + return v, vector.zero end :: TypeToVec6, Rect = function(v) - return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) + return vector.create(v.Min.X, v.Min.Y, v.Max.X), vector.create(v.Max.Y, 0, 0) end :: TypeToVec6 } @@ -143,10 +123,10 @@ setmetatable(vec6_to_type, invalid_type) -- maps spring data to its corresponding output node -- lifetime of spring data is tied to output node -local springs: { [SpringData]: SourceNode } = {} -setmetatable(springs, { __mode = "v" }) +local springs: { [SpringState]: SourceNode } = {} +setmetatable(springs :: any, { __mode = "v" }) -local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T +local function spring(source: () -> T, period: number?, damping_ratio: number?): (() -> T, SpringSettings) local owner = assert_stable_scope() -- https://en.wikipedia.org/wiki/Damping @@ -164,17 +144,17 @@ local function spring(source: () -> T, period: number?, damping_ratio: number throw("spring damping too high, consider reducing damping or increasing period") end - local data: SpringData = { + local data: SpringState = { k = k, c = c, - x0_123 = ZERO, - x1_123 = ZERO, - v_123 = ZERO, + x0_123 = vector.zero, + x1_123 = vector.zero, + v_123 = vector.zero, - x0_456 = ZERO, - x1_456 = ZERO, - v_456 = ZERO, + x0_456 = vector.zero, + x1_456 = vector.zero, + v_456 = vector.zero, source_value = false :: any, } @@ -185,7 +165,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local value = source() data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) data.source_value = value - springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs + springs[data] = output return value end @@ -199,6 +179,28 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- set output to goal output.cache = data.source_value + local setter = function(p) + local x = p.position + local v = p.velocity + local dv = p.impulse + + if x then + data.x0_123, data.x0_456 = type_to_vec6[typeof(x)](x) + end + + if v then + data.v_123, data.v_456 = type_to_vec6[typeof(v)](v) + end + + if dv then + local dv_123, dv_456 = type_to_vec6[typeof(dv)](dv) + data.v_123 += dv_123 + data.v_456 += dv_456 + end + + springs[data] = output + end :: SpringSettings + return function(...) if select("#", ...) == 0 then -- no args were given push_child_to_scope(output) @@ -210,8 +212,8 @@ local function spring(source: () -> T, period: number?, damping_ratio: number data.x0_123, data.x0_456 = type_to_vec6[typeof(v)](v) -- reset velocity - data.v_123 = ZERO - data.v_456 = ZERO + data.v_123 = vector.zero + data.v_456 = vector.zero -- schedule spring springs[data] = output @@ -220,7 +222,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number output.cache = v return v - end + end, setter end local function step_springs(dt: number) @@ -264,7 +266,7 @@ end local remove_queue = {} local function update_spring_sources() - for data, output in next, springs do + for data, output in springs do local x0_123, x1_123, v_123, x0_456, x1_456, v_456 = data.x0_123, data.x1_123, data.v_123, @@ -280,7 +282,7 @@ local function update_spring_sources() if max_difference == TOLERANCE_VECTOR then -- close enough to target, unshedule spring and set value to target - table.insert(remove_queue, data) + springs[data] = nil output.cache = data.source_value else output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) @@ -288,12 +290,6 @@ local function update_spring_sources() update_descendants(output) end - - for _, data in next, remove_queue do - springs[data] = nil - end - - table.clear(remove_queue) end return function() diff --git a/test/benchmark.luau b/test/benchmarks.luau similarity index 100% rename from test/benchmark.luau rename to test/benchmarks.luau From 58a31a1b329e922dc86c554e8220012ab7238f1b Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Fri, 27 Dec 2024 22:43:01 +0000 Subject: [PATCH 59/94] Try improve error reporting --- CHANGELOG.md | 2 + src/apply.luau | 5 +- src/batch.luau | 5 +- src/cleanup.luau | 5 +- src/context.luau | 7 ++- src/create.luau | 7 ++- src/graph.luau | 31 +++++++---- src/lib.luau | 5 +- src/maps.luau | 9 ++-- src/root.luau | 8 ++- src/source.luau | 4 +- src/spring.luau | 7 +-- src/switch.luau | 5 +- src/throw.luau | 5 -- src/untrack.luau | 4 +- test/stacktrace-test.luau | 110 ++++++++++++++++++++++++++++++++++++++ 16 files changed, 164 insertions(+), 55 deletions(-) delete mode 100644 src/throw.luau create mode 100644 test/stacktrace-test.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c6303b..ef49bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). for this. - Implicit effects to set children now unparent all children when the effect is destroyed. +- Error reporting should be improved with better formatting when effects invoke + other effects and no more loss of stack traces. ### Removed diff --git a/src/apply.luau b/src/apply.luau index 2b30ba6..95645da 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,7 +1,6 @@ local typeof = game and typeof or require "../test/mock".typeof :: never local flags = require "./flags" -local throw = require "./throw" local implicit_effect = require "./implicit_effect" local _, is_action = require "./action"() local graph = require "./graph" @@ -67,7 +66,7 @@ local function process_properties(properties: Map, instance: I if type(property) == "string" then if flags.strict then -- check for duplicate property assignment at nesting depth if cache.nested_debug[depth][property] then - throw(`duplicate property {property} at depth {depth}`) + error(`duplicate property {property} at depth {depth}`, 0) end cache.nested_debug[depth][property] = true end @@ -104,7 +103,7 @@ end -- applies table of nested properties to an instance using full vide semantics local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T if not properties then - throw("attempt to call a constructor returned by create() with no properties") + error "attempt to call a constructor returned by create() with no properties" end -- queue parent assignment if any for last diff --git a/src/batch.luau b/src/batch.luau index 4cd7581..e3e6d40 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -1,5 +1,4 @@ local flags = require "./flags" -local throw = require "./throw" local graph = require "./graph" local function batch(setter: () -> ()) @@ -11,14 +10,14 @@ local function batch(setter: () -> ()) from = graph.get_update_queue_length() end - local ok, err: string? = pcall(setter) + local ok, err: string? = xpcall(setter, debug.traceback) if not already_batching then flags.batch = false graph.flush_update_queue(from) end - if not ok then throw(`error occured while batching updates: {err}`) end + if not ok then error(`error occured while batching updates: {err}`, 0) end end return batch diff --git a/src/cleanup.luau b/src/cleanup.luau index 20d188b..6c46bbf 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,6 +1,5 @@ local typeof = game and typeof or require "../test/mock".typeof :: never -local throw = require "./throw" local graph = require "./graph" local get_scope = graph.get_scope local push_cleanup = graph.push_cleanup @@ -14,14 +13,14 @@ local function helper(obj: any) elseif obj.disconnect then function() obj:disconnect() end elseif obj.Destroy then function() obj:Destroy() end elseif obj.Disconnect then function() obj:Disconnect() end - else throw("cannot cleanup given object") + else error "cannot cleanup given object" end local function cleanup(value: unknown) local scope = get_scope() if not scope then - throw "cannot cleanup outside a stable or reactive scope" + error "cannot cleanup outside a stable or reactive scope" end; assert(scope) if type(value) == "function" then diff --git a/src/context.luau b/src/context.luau index a46ad5a..2ce3eff 100644 --- a/src/context.luau +++ b/src/context.luau @@ -1,4 +1,3 @@ -local throw = require "./throw" local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node @@ -44,10 +43,10 @@ local function context(...: T): Context if has_default ~= nil then return default_value else - throw("attempt to get context when no context is set and no default context is set") + error("attempt to get context when no context is set and no default context is set", 0) end else -- set - if not scope then return throw("attempt to set context outside of a vide scope") end + if not scope then return error("attempt to set context outside of a vide scope", 0) end local value, component = ... @@ -62,7 +61,7 @@ local function context(...: T): Context pop_scope() if not ok then - throw(`error while running context:\n\n{result}`) + error(`error while running context:\n\n{result}`, 0) end return result diff --git a/src/create.luau b/src/create.luau index dea8ca7..ebcbf54 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,7 +1,6 @@ local typeof = game and typeof or require "../test/mock".typeof :: never local Instance = game and Instance or require "../test/mock".Instance :: never -local throw = require "./throw" local defaults = require "./defaults" local apply = require "./apply" @@ -10,7 +9,7 @@ local ctor_cache = {} :: { [string]: () -> Instance } setmetatable(ctor_cache :: any, { __index = function(self, class) local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then throw(`invalid class name, could not create instance of class { class }`) end + if not ok then error(`invalid class name, could not create instance of class { class }`, 0) end local default: { [string]: unknown }? = defaults[class] if default then @@ -35,7 +34,7 @@ end local function clone_instance(instance: Instance) return function(properties: Props): Instance local clone = instance:Clone() - if not clone then throw "attempt to clone a non-archivable instance" end + if not clone then error "attempt to clone a non-archivable instance" end return apply(clone, properties) end end @@ -47,7 +46,7 @@ local function create(class_or_instance: string | Instance, props: Props?): ((Pr elseif typeof(class_or_instance) == "Instance" then result = clone_instance(class_or_instance) else - throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) + error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0) return nil :: never end if props then diff --git a/src/graph.luau b/src/graph.luau index be58b15..360c0cb 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -1,4 +1,3 @@ -local throw = require "./throw" local flags = require "./flags" export type SourceNode = { @@ -22,9 +21,23 @@ export type Node = { local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack +local function efn(err: string) + local trace = debug.traceback(err, 2) + + if string.find(err, "^effect error stacktrace") then -- if effect error is nested + trace = string.gsub(" " .. trace, "\n", function() -- indent entire error + return "\n " + end) + end + + trace ..= "\nsource update stacktrace:" +return trace +end + local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) + local thread = coroutine.create(xpcall) - local function efn(err: string) return debug.traceback(err, 3) end + --local function efn(err: string) return debug.traceback(err, 3) end local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) assert(resume_ok) @@ -45,9 +58,9 @@ local function assert_stable_scope(): Node if not scope then local caller_name = debug.info(2, "n") - return throw(`cannot use {caller_name}() outside a stable or reactive scope`) + return error(`cannot use {caller_name}() outside a stable or reactive scope`, 0) elseif scope.effect then - throw("cannot create a new reactive scope inside another reactive scope") + error("cannot create a new reactive scope inside another reactive scope", 0) end return scope @@ -81,8 +94,8 @@ end local function flush_cleanups(node: Node) if node.cleanups then for _, fn in next, node.cleanups do - local ok, err: string? = pcall(fn) - if not ok then throw(`cleanup error: {err}`) end + local ok, err: string? = xpcall(fn, debug.traceback) + if not ok then error(`cleanup error: {err}`, 0) end end table.clear(node.cleanups) @@ -107,7 +120,7 @@ end local function destroy(node: Node) if flags.strict and table.find(scopes, node) then - throw("attempt to destroy an active scope") + error("attempt to destroy an active scope", 0) end flush_cleanups(node) @@ -150,7 +163,7 @@ local function evaluate_node(node: Node) if not ok then table.clear(update_queue) update_queue.n = 0 - throw(`effect stacktrace:\n{new_value :: string}`) + error(`effect error stacktrace\n{new_value :: string}`, 0) end node.cache = new_value :: T @@ -170,7 +183,7 @@ local function evaluate_node(node: Node) if not ok then table.clear(update_queue) update_queue.n = 0 - throw(`effect stacktrace:\n{new_value}\n`) + error(`effect error:\n{new_value}\n`, 0) end node.cache = new_value diff --git a/src/lib.luau b/src/lib.luau index a54bd32..3d7f3be 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -18,7 +18,6 @@ local indexes, values = require "./maps"() local spring, update_springs = require "./spring"() local action = require "./action"() local changed = require "./changed" -local throw = require "./throw" local flags = require "./flags" export type Source = source.Source @@ -98,7 +97,7 @@ local vide = { setmetatable(vide :: any, { __index = function(_, index: unknown): () if flags[index] == nil then - throw(`{tostring(index)} is not a valid member of vide`) + error(`{tostring(index)} is not a valid member of vide`, 0) else return flags[index] end @@ -106,7 +105,7 @@ setmetatable(vide :: any, { __newindex = function(_, index: unknown, value: unknown) if flags[index] == nil then - throw(`{tostring(index)} is not a valid member of vide`) + error(`{tostring(index)} is not a valid member of vide, 0`) else flags[index] = value end diff --git a/src/maps.luau b/src/maps.luau index bd59a6c..0f95695 100644 --- a/src/maps.luau +++ b/src/maps.luau @@ -1,4 +1,3 @@ -local throw = require "./throw" local flags = require "./flags" local graph = require "./graph" type Node = graph.Node @@ -20,7 +19,7 @@ local function check_primitives(t: {}) for _, v in next, t do if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end - throw("table source map cannot return primitives") + error("table source map cannot return primitives", 0) end end @@ -71,7 +70,7 @@ local function indexes(input: () -> Map, transform: (() -> VI, push_scope(scope) - local ok, result = pcall(transform, function() + local ok, result = xpcall(transform, debug.traceback, function() push_child_to_scope(node) return node.cache end, i) @@ -132,7 +131,7 @@ local function values(input: () -> Map, transform: (VI, () -> local cache = {} for _, v in next, data do if cache[v] ~= nil then - throw "duplicate table value detected" + error "duplicate table value detected" end cache[v] = true end @@ -154,7 +153,7 @@ local function values(input: () -> Map, transform: (VI, () -> push_scope(scope) - local ok, result = pcall(transform, v, function() + local ok, result = xpcall(transform, debug.traceback, v, function() push_child_to_scope(node) return node.cache end) diff --git a/src/root.luau b/src/root.luau index d8dc230..bc9c2fc 100644 --- a/src/root.luau +++ b/src/root.luau @@ -1,4 +1,3 @@ -local throw = require "./throw" local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node @@ -14,21 +13,20 @@ local function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) refs[node] = true -- prevent gc of root node local destroy = function() - if not refs[node] then throw "root already destroyed" end + if not refs[node] then error "root already destroyed" end refs[node] = nil destroy(node) end push_scope(node) - local function efn(err: string) return debug.traceback(err, 3) end - local result = { xpcall(fn, efn, destroy) } + local result = { xpcall(fn, debug.traceback, destroy) } pop_scope() if not result[1] then destroy() - throw(`error while running root():\n\n{result[2]}`) + error(`error while running root():\n\n{result[2]}`, 0) end return destroy, unpack(result :: any, 2) diff --git a/src/source.luau b/src/source.luau index 0b89637..d7aa53d 100644 --- a/src/source.luau +++ b/src/source.luau @@ -9,7 +9,7 @@ export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source local node = create_source_node(initial_value) - return function(...): T + local function update_source(...): T if select("#", ...) == 0 then -- no args were given push_child_to_scope(node) return node.cache @@ -24,6 +24,8 @@ local function source(initial_value: T): Source update_descendants(node) return v end + + return update_source end return source :: ((initial_value: T) -> Source) & (() -> Source) diff --git a/src/spring.luau b/src/spring.luau index 053c945..aaf6789 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,4 +1,3 @@ -local throw = require "./throw" local graph = require "./graph" type Node = graph.Node type SourceNode = graph.SourceNode @@ -114,7 +113,7 @@ local vec6_to_type = { local invalid_type = { __index = function(_, t: string) - throw(`cannot spring type {t}`) + error(`cannot spring type {t}`, 0) end } @@ -141,7 +140,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- todo: is there a solution other than reducing step size? -- todo: this does not catch all solver exploding cases if c > UPDATE_RATE*2 then -- solver will explode if this is true - throw("spring damping too high, consider reducing damping or increasing period") + error("spring damping too high, consider reducing damping or increasing period", 0) end local data: SpringState = { @@ -263,8 +262,6 @@ local function step_springs(dt: number) end end -local remove_queue = {} - local function update_spring_sources() for data, output in springs do local x0_123, x1_123, v_123, diff --git a/src/switch.luau b/src/switch.luau index cb7cba4..547fc80 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -1,4 +1,3 @@ -local throw = require "./throw" local graph = require "./graph" type Node = graph.Node type SourceNode = graph.SourceNode @@ -32,7 +31,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () if component == nil then return nil end if type(component) ~= "function" then - throw "map must map a value to a function" + error "map must map a value to a function" end local new_scope = create_node(owner, false, false) @@ -40,7 +39,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () push_scope(new_scope) - local ok, result = pcall(component) + local ok, result = xpcall(component, debug.traceback) pop_scope() diff --git a/src/throw.luau b/src/throw.luau deleted file mode 100644 index 4135f69..0000000 --- a/src/throw.luau +++ /dev/null @@ -1,5 +0,0 @@ -local function VIDE_ASSERT(msg): any - error(msg, 0) -end - -return VIDE_ASSERT diff --git a/src/untrack.luau b/src/untrack.luau index 90ceee2..577da25 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -10,13 +10,13 @@ local function untrack(source: () -> T): T local effect = scope.effect scope.effect = false - local ok, result = pcall(source) + local ok, result = xpcall(source, debug.traceback) scope.effect = effect :: () -> () if not ok then error(result, 0) end - return result + return result :: T else return source() end diff --git a/test/stacktrace-test.luau b/test/stacktrace-test.luau new file mode 100644 index 0000000..e24c314 --- /dev/null +++ b/test/stacktrace-test.luau @@ -0,0 +1,110 @@ +local vide = require "../" + +do + print "=============================================================" + + local a = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end + +do + print "=============================================================" + + local a = vide.source(1) + local b = vide.source(1) + local c = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + b(vide.untrack(b) + 1) + end) + + vide.effect(function() + b() + c(vide.untrack(c) + 1) + end) + + + vide.effect(function() + c() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end + +do + print "=============================================================" + + local a = vide.source(1) + local b = vide.source(1) + local c = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + vide.untrack(function() -- todo: this trace appearing twice + b(b() + 1) + return nil + end) + end) + + vide.effect(function() + b() + vide.batch(function() + c(vide.untrack(c) + 1) + end) + end) + + + vide.effect(function() + c() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end From c796e48173dfc42d3cad1c0d13bb264190cf2621 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 4 Jan 2025 00:40:51 +0000 Subject: [PATCH 60/94] Add source to `show()` callback --- CHANGELOG.md | 2 ++ src/show.luau | 34 +++++++++++++++++--------- test/tests.luau | 65 ++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef49bb2..fee931d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). effects to set children. - `spring()` returns a second value, a setter to set position, velocity and impulse. +- `show()` now receives a source to its callback returning the current value + of the condition. ### Changed diff --git a/src/show.luau b/src/show.luau index b3c3fef..e33393f 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,16 +1,28 @@ -local switch = require "./switch" +local derive = require "./derive" +local untrack = require "./untrack" -local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? - local function truthy() +local function show(source: () -> T?, component: (() -> T) -> U, fallback: (() -> U)?): () -> U? + local truthy = derive(function() return not not source() - end + end) - return switch(truthy) { - [true] = component, - [false] = fallback, - } + -- seemingly redundant derivation to extend the reactive graph so that + -- the propogation of the source's update is delayed, giving time for + -- the show() scope to be destroyed before potential effects registered + -- by the component can run when they should not run + -- todo: are there cases this method does not cover? + local derived = derive(function() + return source() + end) + + return derive(function() + return + if truthy() then untrack(function() return component(derived :: () -> T) end) + elseif fallback then untrack(fallback) + else nil + end) end -return show :: - ((source: () -> any, component: () -> T) -> () -> T?) & - ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) +return show :: ( + ( (source: () -> T?, component: (() -> T) -> U) -> () -> U? ) +) diff --git a/test/tests.luau b/test/tests.luau index d4c95ae..d79e966 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1073,20 +1073,73 @@ TEST("show()", wrap_root(function() local show = vide.show local root = vide.root - do CASE "main" - -- uses switch() internally, more extensive testing of scoping not needed - local value = source("truey" :: unknown) + do CASE "show component" + local input = source(true) + local function one() return 1 end + + local output = show(input, one) + + CHECK(output() == 1) + input(false) + CHECK(output() == nil) + end + + do CASE "fallback component" + local input = source(true) local function one() return 1 end local function two() return 2 end - local output = show(value, one, two) + local output = show(input, one, two) CHECK(output() == 1) - value(nil) + input(false) CHECK(output() == 2) end - do CASE "alt" + do CASE "updating truth to truthy does not rerun" + local input = source(1) + local count = 0 + + local function component() + count += 1 + return 1 + end + + local output = show(input, component) + + CHECK(count == 1) + CHECK(output() == 1) + input(2) + CHECK(count == 1) + end + + do CASE "updating source passed to component" + local input = source(1 :: number?) + local count = 0 + + show(input :: () -> number?, function(value: () -> number) + vide.cleanup(function() print "destroyed" end) + effect(function() + local v = value() + + count += 1 + + CHECK(v == count) + if v ~= count then error(count) end + end) + + return true + end) + + input(2) + CHECK(count == 2) + input(3) + CHECK(count == 3) + input(nil) + CHECK(count == 3) + end + + do CASE "alt" -- todo: move test local visible = vide.source(true) local count = vide.source(0) From 46a20433566021da6f527d966324e557cce5283f Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Sat, 4 Jan 2025 00:45:55 +0000 Subject: [PATCH 61/94] Ignore `false` passed as a child --- CHANGELOG.md | 1 + src/apply.luau | 2 +- test/tests.luau | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee931d..00f644f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). impulse. - `show()` now receives a source to its callback returning the current value of the condition. +- Ignore `false` passed as a child. ### Changed diff --git a/src/apply.luau b/src/apply.luau index 95645da..05ba98a 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -93,7 +93,7 @@ local function process_properties(properties: Map, instance: I else process_properties(value :: Map, instance, cache, depth + 1) end - else + elseif type(value) == "userdata" then (value :: Instance).Parent = instance -- parent child end end diff --git a/test/tests.luau b/test/tests.luau index d79e966..36a22b8 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -819,6 +819,20 @@ TEST("create()", wrap_root(function() CHECK(frame:FindFirstChild "G") end + do CASE "set false as child" + create "Frame" { + false + } + + create "Frame" { + function() return false end + } + + create "Frame" { + function() return { false } end + } + end + do CASE "binding properties to source" local name = source("Hi") local text = source("Bye") From a383c4ce69ddf6e59592e4e46163a29b45e104a2 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Mon, 6 Jan 2025 23:56:49 +0000 Subject: [PATCH 62/94] Fix `show()` edge case --- src/show.luau | 33 ++++++++++++---------- test/tests.luau | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/show.luau b/src/show.luau index e33393f..c4d87db 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,28 +1,33 @@ +local source = require "./source" local derive = require "./derive" +local effect = require "./effect" local untrack = require "./untrack" -local function show(source: () -> T?, component: (() -> T) -> U, fallback: (() -> U)?): () -> U? - local truthy = derive(function() - return not not source() +local function show(input: () -> T?, component: (() -> T) -> U, fallback: (() -> U)?): () -> U? + local filtered_input = source() + + effect(function() + local v = input() + if v then + filtered_input(v) + end end) - -- seemingly redundant derivation to extend the reactive graph so that - -- the propogation of the source's update is delayed, giving time for - -- the show() scope to be destroyed before potential effects registered - -- by the component can run when they should not run - -- todo: are there cases this method does not cover? - local derived = derive(function() - return source() + local input_is_truthy = derive(function() + return not not input() end) + -- todo: is this needed? + -- local filtered_input_is_truthy = derive(function() + -- return not not filtered_input() + -- end) + return derive(function() return - if truthy() then untrack(function() return component(derived :: () -> T) end) + if input_is_truthy() then untrack(function() return component(filtered_input :: () -> T) end) elseif fallback then untrack(fallback) else nil end) end -return show :: ( - ( (source: () -> T?, component: (() -> T) -> U) -> () -> U? ) -) +return show diff --git a/test/tests.luau b/test/tests.luau index 36a22b8..cd627fc 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1153,6 +1153,80 @@ TEST("show()", wrap_root(function() CHECK(count == 3) end + do CASE "special strict case" + type Weapon = { + id: string, + enchant: string? + } + + vide.strict = true + + local count = 0 + local branch = 0 + + local weapon = source(nil :: Weapon?) + + effect(function() + weapon() + end) + + effect(function() + weapon() + end) + + show(weapon, function(weapon: () -> Weapon) + local enchant = function() return weapon().enchant end + + show(enchant, function(enchant: () -> string) + effect(function() + local e = enchant() + count += 1 + CHECK(e ~= nil) + if branch == 1 then + CHECK(e == "fire") + elseif branch == 2 then + CHECK(e == "poison") + end + end) + + return {} + end) + + return {} + end) + + effect(function() + weapon() + end) + + effect(function() + weapon() + end) + + branch = 1 + weapon { id = "1", enchant = "fire" } + CHECK(count == 8) + + branch = 2 + weapon { id = "1", enchant = "poison" } + CHECK(count == 10) + + weapon { id = "1", enchant = nil } + CHECK(count == 10) + + branch = 1 + weapon { id = "1", enchant = "fire" } + CHECK(count == 14) + + weapon(nil) + + branch = 2 + weapon { id = "1", enchant = "poison" } + CHECK(count == 22) + + vide.strict = false + end + do CASE "alt" -- todo: move test local visible = vide.source(true) local count = vide.source(0) From b7878753bd8fe131035b51887413b35334851444 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:06:39 +0000 Subject: [PATCH 63/94] Update `untrack()` type to allow no return --- src/untrack.luau | 2 +- test/tests.luau | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/untrack.luau b/src/untrack.luau index 577da25..6e15578 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -22,4 +22,4 @@ local function untrack(source: () -> T): T end end -return untrack +return untrack :: ( (fn: () -> T) -> T ) & ( (fn: () -> ()) -> () ) diff --git a/test/tests.luau b/test/tests.luau index cd627fc..e9365c2 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1250,7 +1250,6 @@ TEST("show()", wrap_root(function() destroyed += 1 end) end) - return nil end) end) end) @@ -2863,7 +2862,6 @@ TEST("strict", wrap_root(function() vide.cleanup(function() count_2 += 1 end) return {} end) - return nil end) end) end) @@ -2890,7 +2888,6 @@ TEST("strict", wrap_root(function() vide.cleanup(function() count_2 += 1 end) return {} end) - return nil end) end) end) From 7064489a369521006135b5a0bd73bde548a631ef Mon Sep 17 00:00:00 2001 From: HarryXChen <51322624+HarryXChen3@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:06:54 -0500 Subject: [PATCH 64/94] Fix typo in scope crash course docs (#46) --- docs/tut/crash-course/6-scope.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index 7775647..e2cea69 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -7,7 +7,7 @@ But the disconnecting of many signals and connections is tedious and verbose. Vide instead operates on the concept of scopes which provides a much cleaner API, given that you follow a few rules. -Thre are two types of scopes: stable and reactive. +There are two types of scopes: stable and reactive. - A scope must be created within another scope. - Stable scopes never rerun. From 37e8e05206529ccc7e4f31710f1612254f2670f8 Mon Sep 17 00:00:00 2001 From: EwDev <74792428+ewd3v@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:31:26 +0100 Subject: [PATCH 65/94] Support nesting Parent (#48) * allow nesting parent property * update changelog * fix mock instances Parent property not being a proxy * add tests Closes #47 --- CHANGELOG.md | 2 ++ src/apply.luau | 16 +++++++++++----- test/mock.luau | 35 +++++++++++++++++++---------------- test/tests.luau | 7 +++++++ 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f644f..2cde25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). destroyed. - Error reporting should be improved with better formatting when effects invoke other effects and no more loss of stack traces. +- Nesting parent properties now work, and they are now also checked for + duplicates like other properties. ### Removed diff --git a/src/apply.luau b/src/apply.luau index 05ba98a..85020ff 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -23,6 +23,9 @@ type Cache = { Array<(Instance) -> ()> -- action callbacks >, + -- what to parent the instance to after running actions + parent: unknown, + -- cache to detect duplicate property setting at same nesting depth nested_debug: Map< number, -- depth @@ -47,6 +50,7 @@ local function borrow_cache(): Cache actions = setmetatable({} :: any, { -- lazy init __index = function(self, i) self[i] = {}; return self[i] end }), + parent = nil, nested_debug = setmetatable({} :: any, { __index = function(self, i: number) self[i] = {}; return self[i] end }), @@ -61,8 +65,6 @@ end local function process_properties(properties: Map, instance: Instance, cache: Cache, depth: number) for property, value in properties do - if property == "Parent" then continue end - if type(property) == "string" then if flags.strict then -- check for duplicate property assignment at nesting depth if cache.nested_debug[depth][property] then @@ -71,6 +73,11 @@ local function process_properties(properties: Map, instance: I cache.nested_debug[depth][property] = true end + if property == "Parent" then + cache.parent = value + continue + end + if type(value) == "function" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then table.insert(cache.events, property) -- add event name to buffer @@ -106,9 +113,6 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown error "attempt to call a constructor returned by create() with no properties" end - -- queue parent assignment if any for last - local parent: unknown = properties.Parent - local caches = borrow_cache() local events = caches.events local actions = caches.actions @@ -135,6 +139,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end end + local parent = caches.parent if parent then if type(parent) == "function" then implicit_effect.parent(instance, parent :: () -> Instance) @@ -145,6 +150,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown table.clear(events) for _, queued in next, actions do table.clear(queued) end + caches.parent = nil if flags.strict then table.clear(nested_debug) end table.clear(nested_stack) diff --git a/test/mock.luau b/test/mock.luau index 34564e6..a1f22a6 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -86,6 +86,9 @@ local Instance = {} :: any do local proxies = {} :: { [Data]: userdata? } setmetatable(proxies :: any, { __mode = "v" }) + -- allocate variables for the metamethods as __index and get_proxy cross refrences each other + local __index, __newindex + local function get_data(userdata: userdata): Data local function f(userdata: userdata): ProxyMT return getmetatable(userdata :: any) @@ -94,6 +97,19 @@ local Instance = {} :: any do return f(userdata).data end + local function get_proxy(data: Data): userdata + return proxies[data] or (function() + local userdata = newproxy(true) + local proxy = getmetatable(userdata) + proxy.proxy = userdata + proxy.data = data + proxy.__index = __index + proxy.__newindex = __newindex + proxies[data] = userdata + return userdata + end)() + end + local function is_instance(value: unknown): boolean local mt = getmetatable(value :: any) return mt and mt.data and mt.data.type == "Instance" @@ -101,16 +117,16 @@ local Instance = {} :: any do local methods = {} - local function __index(userdata: userdata, property: string): () + __index = function(userdata: userdata, property: string): () local data = get_data(userdata) return if methods[property] then methods[property] elseif property == "Name" then data.name - elseif property == "Parent" then data.parent + elseif property == "Parent" then (data.parent and get_proxy(data.parent)) elseif property == "Destroying" then data.destroying else data.properties[property] end - local function __newindex(userdata: userdata, property: string, value: unknown) + __newindex = function(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 @@ -135,19 +151,6 @@ local Instance = {} :: any do end end - local function get_proxy(data: Data): userdata - return proxies[data] or (function() - local userdata = newproxy(true) - local proxy = getmetatable(userdata) - proxy.proxy = userdata - proxy.data = data - proxy.__index = __index - proxy.__newindex = __newindex - proxies[data] = userdata - return userdata - end)() - end - function Instance.new(class: string): Instance local data = { name = "UNNAMED", diff --git a/test/tests.luau b/test/tests.luau index e9365c2..14409bb 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -759,6 +759,13 @@ TEST("create()", wrap_root(function() CHECK(text.Text == "test") end + do CASE "set nested parent" + local frame = create "Frame" {} + local text = create "TextLavel" { { Parent = frame } } + CHECK(frame:GetChildren()[1] == text) + CHECK(text.Parent == frame) + end + do CASE "nested deferred" local text = create "TextLabel" { { From 452ca383f7c039ed9e3f66c1df8e43757f54dfb9 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 27 Mar 2025 19:27:54 +0000 Subject: [PATCH 66/94] Optimize `indexes()` and `values()` --- src/derive.luau | 4 +- src/graph.luau | 4 +- src/indexes.luau | 114 +++++++++++++++++++++++++ src/lib.luau | 20 +++-- src/maps.luau | 216 ----------------------------------------------- src/source.luau | 4 +- src/spring.luau | 4 +- src/switch.luau | 4 +- src/timeout.luau | 27 ++++++ src/values.luau | 143 +++++++++++++++++++++++++++++++ test/tests.luau | 40 ++++----- 11 files changed, 320 insertions(+), 260 deletions(-) create mode 100644 src/indexes.luau delete mode 100644 src/maps.luau create mode 100644 src/timeout.luau create mode 100644 src/values.luau diff --git a/src/derive.luau b/src/derive.luau index 73bbfeb..b941185 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,6 +1,6 @@ local graph = require "./graph" local create_node = graph.create_node -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node @@ -10,7 +10,7 @@ local function derive(source: () -> T): () -> T evaluate_node(node) return function() - push_child_to_scope(node) + push_scope_as_child_of(node) return node.cache end end diff --git a/src/graph.luau b/src/graph.luau index 360c0cb..c7e37d5 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -245,7 +245,7 @@ local function update_descendants(root: SourceNode) update_queue.n = n0 end -local function push_child_to_scope(node: SourceNode) +local function push_scope_as_child_of(node: SourceNode) local scope = get_scope() if scope and scope.effect then -- do not track nodes with no effect push_child(node, scope) @@ -302,7 +302,7 @@ return table.freeze { push_cleanup = push_cleanup, destroy = destroy, flush_cleanups = flush_cleanups, - push_child_to_scope = push_child_to_scope, + push_scope_as_child_of = push_scope_as_child_of, update_descendants = update_descendants, push_child = push_child, create_node = create_node, diff --git a/src/indexes.luau b/src/indexes.luau new file mode 100644 index 0000000..acad4c0 --- /dev/null +++ b/src/indexes.luau @@ -0,0 +1,114 @@ +local flags = require "./flags" +local graph = require "./graph" +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local create_source_node = graph.create_source_node +local push_scope_as_child_of = graph.push_scope_as_child_of +local update_descendants = graph.update_descendants +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local evaluate_node = graph.evaluate_node +local destroy = graph.destroy + +type Map = { [K]: V } + +local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO, delay: number?): () -> { VO } + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) + + local input_cache = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local scopes = {} :: Map> + + local function update_children(data) + local children_need_update = false + + -- remove old indexes + for i in input_cache do + if data[i] == nil then + destroy(scopes[i]) + input_cache[i] = nil + output_cache[i] = nil + input_nodes[i] = nil + scopes[i] = nil + children_need_update = true + end + end + + push_scope(subowner) + + -- process new or changed values + for i, v in data do + local cv = input_cache[i] + + if cv ~= v then + input_cache[i] = v + + if cv == nil then -- create new scope and run transform + local scope = create_node(subowner, false, false) + scopes[i] = scope :: Node + + local node = create_source_node(v) + + push_scope(scope) + + local ok, result = xpcall(transform, debug.traceback, function() + push_scope_as_child_of(node) + return node.cache + end, i) + + pop_scope() + + if not ok then + pop_scope() -- subowner scope + error(result, 0) + end + + input_nodes[i] = node + output_cache[i] = result + children_need_update = true + else -- update source + input_nodes[i].cache = v + update_descendants(input_nodes[i]) + end + end + end + + pop_scope() + + if children_need_update then + local output_array_size = #output_cache + local output_array + + -- check if the table contains a dictionary section + if output_array_size > 0 and next(output_cache, output_array_size) == nil then + output_array = table.clone(output_cache) + else + output_array = table.create(output_array_size) + for _, v in output_cache do + table.insert(output_array, v) + end + end + + return output_array + else + return nil + end + end + + local node = create_node(owner, function(pre_children) + return update_children(input()) or pre_children + end, {}) + + evaluate_node(node) + + return function() + push_scope_as_child_of(node) + return node.cache + end +end + +return indexes diff --git a/src/lib.luau b/src/lib.luau index 3d7f3be..9ad2d48 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -14,10 +14,12 @@ local batch = require "./batch" local context = require "./context" local switch = require "./switch" local show = require "./show" -local indexes, values = require "./maps"() +local indexes = require "./indexes" +local values = require "./values" local spring, update_springs = require "./spring"() local action = require "./action"() local changed = require "./changed" +local timeout, update_timeouts = require "./timeout"() local flags = require "./flags" export type Source = source.Source @@ -26,17 +28,17 @@ export type Context = context.Context export type context = Context local function step(dt: number) - if game then - debug.profilebegin("VIDE STEP") - debug.profilebegin("VIDE SPRING") - end + if game then debug.profilebegin("VIDE STEP") end + if game then debug.profilebegin("VIDE SPRING") end update_springs(dt) + if game then debug.profileend() end - if game then - debug.profileend() - debug.profileend() - end + if game then debug.profilebegin("VIDE SCHEDULER") end + update_timeouts(dt) + if game then debug.profileend() end + + if game then debug.profileend() end end local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) diff --git a/src/maps.luau b/src/maps.luau deleted file mode 100644 index 0f95695..0000000 --- a/src/maps.luau +++ /dev/null @@ -1,216 +0,0 @@ -local flags = require "./flags" -local graph = require "./graph" -type Node = graph.Node -type SourceNode = graph.SourceNode -local create_node = graph.create_node -local create_source_node = graph.create_source_node -local push_child_to_scope = graph.push_child_to_scope -local update_descendants = graph.update_descendants -local assert_stable_scope = graph.assert_stable_scope -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope -local evaluate_node = graph.evaluate_node -local destroy = graph.destroy - -type Map = { [K]: V } - -local function check_primitives(t: {}) - if not flags.strict then return end - - for _, v in next, t do - if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end - error("table source map cannot return primitives", 0) - end -end - -local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = assert_stable_scope() - local subowner = create_node(owner, false, false) - - local input_cache = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local remove_queue = {} :: { K } - local scopes = {} :: Map> - - local function update_children(data) - -- queue removed values - for i in next, input_cache do - if data[i] == nil then - table.insert(remove_queue, i) - end - end - - -- remove queued values - for _, i in next, remove_queue do - destroy(scopes[i]) - - input_cache[i] = nil - output_cache[i] = nil - input_nodes[i] = nil - scopes[i] = nil - end - - table.clear(remove_queue) - - push_scope(subowner) - - -- process new or changed values - for i, v in next, data do - local cv = input_cache[i] - - if cv ~= v then - input_cache[i] = v - - if cv == nil then -- create new scope and run transform - local scope = create_node(subowner, false, false) - scopes[i] = scope :: Node - - local node = create_source_node(v) - - push_scope(scope) - - local ok, result = xpcall(transform, debug.traceback, function() - push_child_to_scope(node) - return node.cache - end, i) - - pop_scope() - - if not ok then - pop_scope() -- subowner scope - error(result, 0) - end - - input_nodes[i] = node - output_cache[i] = result - else -- update source - input_nodes[i].cache = v - update_descendants(input_nodes[i]) - end - end - end - - pop_scope() - - local output_array = table.create(#scopes) - for _, v in next, output_cache do - table.insert(output_array, v) - end - check_primitives(output_array) - - return output_array - end - - local node = create_node(owner, function() - return update_children(input()) - end, false :: any) - - evaluate_node(node) - - return function() - push_child_to_scope(node) - return node.cache - end -end - -local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = assert_stable_scope() - local subowner = create_node(owner, false, false) - - 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) - local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up - - if flags.strict then - local cache = {} - for _, v in next, data do - if cache[v] ~= nil then - error "duplicate table value detected" - end - cache[v] = true - end - end - - push_scope(subowner) - - -- process data - for i, v in next, data do - new_input_cache[v] = i - - local cv = cur_input_cache[v] - - if cv == nil then -- create new scope and run transform - local scope = create_node(subowner, false, false) - scopes[v] = scope :: Node - - local node = create_source_node(i) - - push_scope(scope) - - local ok, result = xpcall(transform, debug.traceback, v, function() - push_child_to_scope(node) - return node.cache - end) - - pop_scope() - - if not ok then - pop_scope() -- subowner scope - error(result, 0) - end - - input_nodes[v] = node - output_cache[v] = result - else -- update source - if cv ~= i then - input_nodes[v].cache = i - update_descendants(input_nodes[v]) - end - - cur_input_cache[v] = nil - end - end - - pop_scope() - - -- remove old values - for v in next, cur_input_cache do - destroy(scopes[v]) - - output_cache[v] = nil - input_nodes[v] = nil - scopes[v] = nil - end - - -- update buffer cache - table.clear(cur_input_cache) - cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache - - local output_array = table.create(#scopes) - for _, v in next, output_cache do - table.insert(output_array, v) - end - check_primitives(output_array) - - return output_array - end - - local node = create_node(owner, function() - return update_children(input()) - end, false :: any) - - evaluate_node(node) - - return function() - push_child_to_scope(node) - return node.cache - end -end - -return function() return indexes, values end diff --git a/src/source.luau b/src/source.luau index d7aa53d..1da515a 100644 --- a/src/source.luau +++ b/src/source.luau @@ -1,7 +1,7 @@ local graph = require "./graph" type Node = graph.Node local create_source_node = graph.create_source_node -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local update_descendants = graph.update_descendants export type Source = (() -> T) & ((value: T) -> T) @@ -11,7 +11,7 @@ local function source(initial_value: T): Source local function update_source(...): T if select("#", ...) == 0 then -- no args were given - push_child_to_scope(node) + push_scope_as_child_of(node) return node.cache end diff --git a/src/spring.luau b/src/spring.luau index aaf6789..988bc00 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -6,7 +6,7 @@ local create_source_node = graph.create_source_node local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node local update_descendants = graph.update_descendants -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local UPDATE_RATE = 120 local TOLERANCE = 0.001 @@ -202,7 +202,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number return function(...) if select("#", ...) == 0 then -- no args were given - push_child_to_scope(output) + push_scope_as_child_of(output) return output.cache end diff --git a/src/switch.luau b/src/switch.luau index 547fc80..c6390dd 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -3,7 +3,7 @@ type Node = graph.Node type SourceNode = graph.SourceNode local create_node = graph.create_node local evaluate_node = graph.evaluate_node -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local destroy = graph.destroy local assert_stable_scope = graph.assert_stable_scope local push_scope = graph.push_scope @@ -53,7 +53,7 @@ local function switch(source: () -> T): (map: Map U)?)>) -> () evaluate_node(node) return function() - push_child_to_scope(node) + push_scope_as_child_of(node) return node.cache end end diff --git a/src/timeout.luau b/src/timeout.luau new file mode 100644 index 0000000..16319e3 --- /dev/null +++ b/src/timeout.luau @@ -0,0 +1,27 @@ +local queue = {} :: { + { t: number, fn: () -> (), cancel: boolean } +} + +local function timeout(t: number, fn: () -> ()) + local handle = { t = t, fn = fn, cancel = false } + table.insert(queue, handle) + return handle +end + +local function update_timeouts(dt: number) + for i = #queue, 1, -1 do + local handle = queue[i] + handle.t -= dt + + if handle.cancel or handle.t <= 0 then + queue[i] = queue[#queue] + queue[#queue] = nil + + if not handle.cancel then + handle.fn() + end + end + end +end + +return function() return timeout, update_timeouts end diff --git a/src/values.luau b/src/values.luau new file mode 100644 index 0000000..7edc4f4 --- /dev/null +++ b/src/values.luau @@ -0,0 +1,143 @@ +local flags = require "./flags" +local graph = require "./graph" +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local create_source_node = graph.create_source_node +local push_scope_as_child_of = graph.push_scope_as_child_of +local update_descendants = graph.update_descendants +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local evaluate_node = graph.evaluate_node +local destroy = graph.destroy + +type Map = { [K]: V } + +local function check_primitives(t: {}) + if not flags.strict then return end + + for _, v in t do + if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end + error("table source map cannot return primitives", 0) + end +end + +local function values(input: () -> Map, transform: (VI, () -> K) -> VO, delay: number?): () -> { VO } + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) + + local update_count = 0 + + local caches = {} :: Map, + index_source: SourceNode, + alive_source: SourceNode, + result: VO, + }> + + local function update_children(data: Map) + local count = update_count + update_count += 1 + + local children_need_update = false + + if flags.strict then + local cache = {} + for _, v in data do + if cache[v] ~= nil then + error "duplicate table value detected" + end + cache[v] = true + end + end + + push_scope(subowner) + + -- process data + for i, v in data do + local cache = caches[v] + + if cache == nil then -- create new scope and run transform + local scope = create_node(subowner, false, false) + local index_source = create_source_node(i) + + local new_cache = { + count = count, + index = i, + scope = scope :: Node, + index_source = index_source :: SourceNode, + alive_source = nil :: any, + result = false :: any, + } + -- must be set before transform is run so that the scope can + -- be destroyed if the transform itself updates the input + -- which lets the strict active scope destruction check work + caches[v] = new_cache + + push_scope(scope) + + local ok, result = xpcall(transform, debug.traceback, v, function() + push_scope_as_child_of(index_source) + return index_source.cache + end) + + pop_scope() + + if not ok then + pop_scope() -- subowner scope + error(result, 0) + end + + new_cache.result = result + children_need_update = true + else -- update source + cache.count = count + + if cache.index ~= i then + cache.index = i + cache.index_source.cache = i + update_descendants(cache.index_source) + end + end + end + + pop_scope() + + -- remove old values + for v, cache in caches do + if cache.count < count then + destroy(cache.scope) + caches[v] = nil + children_need_update = true + end + end + + if children_need_update then + local output_array = table.create(#data) + for _, cache in caches do + table.insert(output_array, cache.result) + end + check_primitives(output_array) + + return output_array + else + return nil + end + end + + local node = create_node(owner, function(pre_children) -- todo: add test + return update_children(input()) or pre_children + end, {}) + + evaluate_node(node) + + return function() + push_scope_as_child_of(node) + return node.cache + end +end + +return values diff --git a/test/tests.luau b/test/tests.luau index 14409bb..254e955 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -35,7 +35,7 @@ vide.strict = false TEST("graph", function() local create_node = graph.create_node - local push_child_to_scope = graph.push_child_to_scope + local push_scope_as_child_of = graph.push_scope_as_child_of local update_descendants = graph.update_descendants local push_child = graph.push_child local get_scope = graph.get_scope @@ -65,8 +65,8 @@ TEST("graph", function() push_scope(c) - push_child_to_scope(a) - push_child_to_scope(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) pop_scope() @@ -83,8 +83,8 @@ TEST("graph", function() local count = 0 local function effect(x) - push_child_to_scope(a) - push_child_to_scope(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) count += 1 return not x end @@ -115,9 +115,9 @@ TEST("graph", function() function c.effect(x) c_cnt += 1; return not x end function d.effect(x) d_cnt += 1; return not x end - push_scope(b); push_child_to_scope(a); pop_scope() - push_scope(c); push_child_to_scope(a); pop_scope() - push_scope(d); push_child_to_scope(b); push_child_to_scope(c); pop_scope() + push_scope(b); push_scope_as_child_of(a); pop_scope() + push_scope(c); push_scope_as_child_of(a); pop_scope() + push_scope(d); push_scope_as_child_of(b); push_scope_as_child_of(c); pop_scope() update_descendants(a) @@ -131,8 +131,8 @@ TEST("graph", function() local a, b, c = node(root), node(root), node(root) function c.effect(x) - push_child_to_scope(a) - push_child_to_scope(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) return not x end @@ -171,10 +171,10 @@ TEST("graph", function() do push_scope(root) clean "root" items_updated = node(root) - push_child_to_scope(items_updated) -- should not + push_scope_as_child_of(items_updated) -- should not do push_scope(items_updated) - push_child_to_scope(items) + push_scope_as_child_of(items) do push_scope(root) do push_scope(scope1) @@ -183,7 +183,7 @@ TEST("graph", function() do push_scope(bind1) clean "bind1" - push_child_to_scope(selected) + push_scope_as_child_of(selected) pop_scope() end pop_scope() end @@ -192,7 +192,7 @@ TEST("graph", function() bind2 = node(scope2) do push_scope(bind2) clean "bind2" - push_child_to_scope(selected) + push_scope_as_child_of(selected) pop_scope() end pop_scope() end pop_scope() end @@ -2345,7 +2345,7 @@ TEST("read()", wrap_root(function() CHECK(read(src) == 1) end - do CASE "push_child_to_scope source" + do CASE "push_scope_as_child_of source" local src = source(0) local count = 0 @@ -2750,16 +2750,6 @@ TEST("strict", wrap_root(function() CHECK(count == 4) end - do CASE "indexes() error if primitive" - local src = source { 1 } - - local ok = pcall(function() - indexes(src, function() return 1 end) - end) - - CHECK(not ok) - end - do CASE "values() error if duplicate" local src = source { 1, 2, 1 } From 6cf770df4e4742cb606635d406098337ae7c3b80 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:41:01 +0000 Subject: [PATCH 67/94] Add spring support for array of numbers --- src/spring.luau | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index 988bc00..3f2975a 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -74,7 +74,11 @@ local type_to_vec6 = { Rect = function(v) return vector.create(v.Min.X, v.Min.Y, v.Max.X), vector.create(v.Max.Y, 0, 0) - end :: TypeToVec6 + end :: TypeToVec6, + + table = function(v) + return vector.create(v[1] or 0, v[2] or 0, v[3] or 0), vector.create(v[4] or 0, 0, 0) + end :: TypeToVec6<{ number }> } local vec6_to_type = { @@ -108,7 +112,11 @@ local vec6_to_type = { Rect = function(a, b) return Rect.new(a.X, a.Y, a.Z, b.X) - end :: Vec6ToType + end :: Vec6ToType, + + table = function(a, b) + return { a.X, a.Y, a.Z, b.X } + end :: Vec6ToType<{ number }> } local invalid_type = { From 86f8574aebc456121c37c391ad6ff05a2b4e8f5f Mon Sep 17 00:00:00 2001 From: alicesaidhi <166900055+alicesaidhi@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:31:40 +0200 Subject: [PATCH 68/94] Add GitHub workflow to build rbxm and publish to pesde and wally (#50) * setup workflow * change versions * fix project json * fix pesde and wally workflow * update pesde toml * wrap tokens ins trings * update versions * ignore lock files --- .github/workflows/build.yml | 33 ++++++++++++++++++++ .github/workflows/wallypesde.yml | 53 ++++++++++++++++++++++++++++++++ .gitignore | 7 +++++ pesde.toml | 23 ++++++++++++++ rokit.toml | 9 ++++++ wally.toml | 2 +- 6 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/wallypesde.yml create mode 100644 pesde.toml create mode 100644 rokit.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9c22f03 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: release + +on: + release: + types: [published] + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: build + run: rojo build default.project.json -o build.rbxm + + - name: release + run: gh release upload ${{github.event.release.tag_name}} build.rbxm + + + \ No newline at end of file diff --git a/.github/workflows/wallypesde.yml b/.github/workflows/wallypesde.yml new file mode 100644 index 0000000..29198ab --- /dev/null +++ b/.github/workflows/wallypesde.yml @@ -0,0 +1,53 @@ +name: publish to wally and pesde + +on: + release: + types: [published] + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + wally: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: login + run: wally login --token "${{ secrets.WALLY_TOKEN }}" + + - name: publish + run: wally publish + + pesde: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: setup pesde + run: pesde install + + - name: login + run: pesde auth login --token "${{ secrets.PESDE_TOKEN }}" + + - name: publish + run: pesde publish --yes + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index bd0480d..da4860c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,10 @@ docs/.vitepress/dist docs/.vitepress/cache docs/node_modules docs/package-lock.json + +luau_packages/ +lune_packages/ +.pesde/ + +wally.lock +pesde.lock \ No newline at end of file diff --git a/pesde.toml b/pesde.toml new file mode 100644 index 0000000..96b9d04 --- /dev/null +++ b/pesde.toml @@ -0,0 +1,23 @@ +name = "centau/vide" +version = "0.4.0" +description = "A reactive Luau library for creating UI." +authors = ["centau"] +repository = "https://github.com/centau/vide" +license = "MIT" +includes = ["src/*", "README.md", "pesde.toml"] + +[target] +environment = "roblox" +build_files = ["src"] +lib = "src/init.luau" + +[indices] +default = "https://github.com/pesde-pkg/index" + +[scripts] +roblox_sync_config_generator = ".pesde/scripts/roblox_sync_config_generator.luau" +sourcemap_generator = ".pesde/scripts/sourcemap_generator.luau" + +[dev_dependencies] +scripts = { name = "pesde/scripts_rojo", version = "^0.1.0", target = "lune" } +rojo = { name = "pesde/rojo", version = "^7.4.4", target = "lune" } diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..9f51fc4 --- /dev/null +++ b/rokit.toml @@ -0,0 +1,9 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + +[tools] +pesde = "daimond113/pesde@0.6.0+registry.0.2.0" +wally = "upliftgames/wally@0.3.2" +rojo = "rojo-rbx/rojo@7.4.4" diff --git a/wally.toml b/wally.toml index 4896b08..2b44317 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.3.1" +version = "0.4.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] From 5b078d118ec54e5cf2f3cb1543facb6689e2b265 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:09:21 +0100 Subject: [PATCH 69/94] Update actions and tooling --- .github/workflows/deploy.yml | 10 +++++----- .github/workflows/unit-test.yml | 4 ++-- .gitignore | 3 +-- pesde.toml | 8 -------- rokit.toml | 2 +- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 10f047c..74258d7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,21 +24,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v4 - name: Install dependencies run: npm install - name: Build with VitePress run: npm run docs:build - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist @@ -52,4 +52,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 69bed32..0e8dab2 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Luau zip - uses: robinraju/release-downloader@v1.6 + uses: robinraju/release-downloader@v1.9 with: repository: Roblox/luau tag: "0.651" diff --git a/.gitignore b/.gitignore index da4860c..18f0f88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .vscode _local -aftman.toml sourcemap.json docs/.vitepress/dist @@ -14,4 +13,4 @@ lune_packages/ .pesde/ wally.lock -pesde.lock \ No newline at end of file +pesde.lock diff --git a/pesde.toml b/pesde.toml index 96b9d04..aec01c5 100644 --- a/pesde.toml +++ b/pesde.toml @@ -13,11 +13,3 @@ lib = "src/init.luau" [indices] default = "https://github.com/pesde-pkg/index" - -[scripts] -roblox_sync_config_generator = ".pesde/scripts/roblox_sync_config_generator.luau" -sourcemap_generator = ".pesde/scripts/sourcemap_generator.luau" - -[dev_dependencies] -scripts = { name = "pesde/scripts_rojo", version = "^0.1.0", target = "lune" } -rojo = { name = "pesde/rojo", version = "^7.4.4", target = "lune" } diff --git a/rokit.toml b/rokit.toml index 9f51fc4..4bc51cb 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,6 +4,6 @@ # New tools can be added by running `rokit add ` in a terminal. [tools] -pesde = "daimond113/pesde@0.6.0+registry.0.2.0" +pesde = "pesde-pkg/pesde@0.6.2+registry.0.2.2" wally = "upliftgames/wally@0.3.2" rojo = "rojo-rbx/rojo@7.4.4" From 3c2fafa1cdf445f840c7e59f1f5f4931d58406bc Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 23 Jun 2025 20:44:14 +0100 Subject: [PATCH 70/94] Fix require paths for latest luau --- .github/workflows/unit-test.yml | 2 +- init.luau | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 0e8dab2..138f29b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -14,7 +14,7 @@ jobs: uses: robinraju/release-downloader@v1.9 with: repository: Roblox/luau - tag: "0.651" + tag: "0.679" fileName: luau-ubuntu.zip out-file-path: bin diff --git a/init.luau b/init.luau index 5ef336f..fcd8183 100644 --- a/init.luau +++ b/init.luau @@ -1,4 +1,4 @@ -local vide = require "./src/lib" +local vide = require "@self/src/lib" export type source = vide.source export type Source = vide.Source From 5f752fe903beb87b2cd1a2e0d86fc3171f865bb5 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:36:51 +0100 Subject: [PATCH 71/94] Add flag to disable setting of default properties --- CHANGELOG.md | 1 + src/create.luau | 25 ++++++++++++++----------- src/flags.luau | 4 +++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cde25a..ef3b525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `show()` now receives a source to its callback returning the current value of the condition. - Ignore `false` passed as a child. +- Flag `vide.defaults` to disable the setting of default properties. ### Changed diff --git a/src/create.luau b/src/create.luau index ebcbf54..193aa0f 100644 --- a/src/create.luau +++ b/src/create.luau @@ -3,23 +3,26 @@ local Instance = game and Instance or require "../test/mock".Instance :: never local defaults = require "./defaults" local apply = require "./apply" +local flags = require "./flags" local ctor_cache = {} :: { [string]: () -> Instance } setmetatable(ctor_cache :: any, { __index = function(self, class) - local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then error(`invalid class name, could not create instance of class { class }`, 0) end - - local default: { [string]: unknown }? = defaults[class] - if default then - for i, v in next, default do - (instance :: any)[i] = v - end - end - local function ctor(properties: Props): Instance - return apply(instance:Clone(), properties) + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then error(`invalid class name { class }`, 0) end + + if flags.defaults then + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in default do + (instance :: any)[i] = v + end + end + end + + return apply(instance, properties) end self[class] = ctor diff --git a/src/flags.luau b/src/flags.luau index bc9424c..c4989d9 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -7,5 +7,7 @@ local is_O2 = inline_test() ~= "inline_test" return { strict = not is_O2, batch = false, - defer_nested_properties = true + defaults = true, + use_default_properties = true, + defer_nested_properties = true, } From 2518e292ed653dc9284b3b42a6d0cce31ee06451 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:58:54 +0100 Subject: [PATCH 72/94] Update spring docs --- docs/api/animation.md | 4 ++-- src/spring.luau | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/animation.md b/docs/api/animation.md index 8719753..9b5baf4 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -11,11 +11,11 @@ Returns a new source with a value always moving torwards the input source value. source: () -> T & Animatable, period: number = 1, damping_ratio: number = 1 - ): (() -> T, Setter) + ): (() -> T, SpringConfig) type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect - type Setter = ({ + type SpringConfig = ({ position: T?, velocity: T?, impulse: T? diff --git a/src/spring.luau b/src/spring.luau index 3f2975a..6e1ce1e 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -186,7 +186,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- set output to goal output.cache = data.source_value - local setter = function(p) + local config = function(p) local x = p.position local v = p.velocity local dv = p.impulse @@ -229,7 +229,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number output.cache = v return v - end, setter + end, config end local function step_springs(dt: number) From 5262ef711c3f804163734436fd3924dddb2861f1 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:44:02 +0100 Subject: [PATCH 73/94] Implement delays for `show()` and `switch()` --- src/branch.luau | 40 ++++++ src/graph.luau | 4 + src/lib.luau | 2 + src/show.luau | 27 ++-- src/switch.luau | 152 +++++++++++++------- test/tests.luau | 367 ++++++++++++++++++++++++++++-------------------- 6 files changed, 376 insertions(+), 216 deletions(-) create mode 100644 src/branch.luau diff --git a/src/branch.luau b/src/branch.luau new file mode 100644 index 0000000..a35c1b0 --- /dev/null +++ b/src/branch.luau @@ -0,0 +1,40 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local destroy = graph.destroy +local get_scope = graph.get_scope + +local function branch(fn: () -> T): (() -> (), T) + local current = get_scope() + if not current then + error(`cannot use branch() outside a stable or reactive scope`, 0) + end + + local parent = current.owner + if not parent or parent.effect then + error(`current scope is not owned by a stable scope`, 0) + end + + local node = create_node(parent, false, false) + + local destroy = function() + destroy(node) + end + + push_scope(node) + + local ok, result = xpcall(fn, debug.traceback) + + pop_scope() + + if not ok then + destroy() + error(`error while running branch():\n\n{result}`, 0) + end + + return destroy, result +end + +return branch diff --git a/src/graph.luau b/src/graph.luau index c7e37d5..4834455 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -148,6 +148,10 @@ local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) if flags.strict then + if table.find(scopes, node) then + error("a scope, that should rerun due to the update of a source, is already active", 0) + end + local initial_value = node.cache for i = 1, 2 do diff --git a/src/lib.luau b/src/lib.luau index 9ad2d48..74a2333 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -1,6 +1,7 @@ local version = { major = 0, minor = 3, patch = 1 } local root = require "./root" +local branch = require "./branch" local mount = require "./mount" local create = require "./create" local apply = require "./apply" @@ -50,6 +51,7 @@ local vide = { -- core root = root, + --branch = branch, mount = mount, create = create, source = source, diff --git a/src/show.luau b/src/show.luau index c4d87db..bbb4218 100644 --- a/src/show.luau +++ b/src/show.luau @@ -2,8 +2,16 @@ local source = require "./source" local derive = require "./derive" local effect = require "./effect" local untrack = require "./untrack" +local switch = require "./switch" -local function show(input: () -> T?, component: (() -> T) -> U, fallback: (() -> U)?): () -> U? +type Array = { T } +type Source = () -> T + +local function show( + input: Source, + component: (Source, Source) -> (Obj, number?), + fallback: ((Source) -> (Obj, number?))? +): Source> local filtered_input = source() effect(function() @@ -17,17 +25,12 @@ local function show(input: () -> T?, component: (() -> T) -> U, fallback: return not not input() end) - -- todo: is this needed? - -- local filtered_input_is_truthy = derive(function() - -- return not not filtered_input() - -- end) - - return derive(function() - return - if input_is_truthy() then untrack(function() return component(filtered_input :: () -> T) end) - elseif fallback then untrack(fallback) - else nil - end) + return switch(input_is_truthy) { + [true] = function(present) + return component(filtered_input, present) + end, + [false] = fallback + } end return show diff --git a/src/switch.luau b/src/switch.luau index c6390dd..b770d26 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -1,61 +1,107 @@ -local graph = require "./graph" -type Node = graph.Node -type SourceNode = graph.SourceNode -local create_node = graph.create_node -local evaluate_node = graph.evaluate_node -local push_scope_as_child_of = graph.push_scope_as_child_of -local destroy = graph.destroy -local assert_stable_scope = graph.assert_stable_scope -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () +type Array = { T } type Map = { [K]: V } +type Source = () -> T +type Component = (Source) -> T -local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = assert_stable_scope() +local function switch_map(input: Source, map: Map>): Source> + local output = source(nil :: nil | Obj | Array) + local caches = {} :: Map (), + present: (boolean?) -> boolean, + object: Obj, + delay: number, + timeout: { cancel: boolean }? + }> + + local function update_output() + local objects = {} + for _, cache in caches do + table.insert(objects, cache.object) + end + + output( + if objects[2] then objects + elseif objects[1] then objects[1] + else nil + ) + end + + effect(function() + local key: K? = input() + + for k, cache in caches do + if k == key then continue end + cache.present(false) + + if cache.delay == 0 then + cache.destroy_scope() + caches[k] = nil + else + if cache.timeout == nil then + cache.timeout = timeout(cache.delay, function() + cache.destroy_scope() + caches[k] = nil + update_output() + end) + end + end + end + + if key ~= nil then + local cache = caches[key] + + if cache then + cache.present(true) + + if cache.timeout then + cache.timeout.cancel = true + cache.timeout = nil + end + else + local component = map[key] + + if component ~= nil then + if type(component) ~= "function" then + error("map must map a value to a function", 0) + end + + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(present) + delay = t + return object + end) + + present(true) + + caches[key] = { + destroy_scope = destroy, + present = present, + object = object, + delay = delay or 0, + timeout = nil + } + end + end + end + + update_output() + end) + + return output +end + +local function switch(input: Source): (map: Map>) -> Source> return function(map) - local last_scope: Node? - local last_component: (() -> U)? - - local function update(cached): U? - local component = map[source()] - if component == last_component then return cached end - last_component = component - - if last_scope then - destroy(last_scope :: Node) - last_scope = nil - end - - if component == nil then return nil end - - if type(component) ~= "function" then - error "map must map a value to a function" - end - - local new_scope = create_node(owner, false, false) - last_scope = new_scope :: Node - - push_scope(new_scope) - - local ok, result = xpcall(component, debug.traceback) - - pop_scope() - - if not ok then error(result, 0) end - - return result - end - - local node = create_node(owner, update, nil) - - evaluate_node(node) - - return function() - push_scope_as_child_of(node) - return node.cache - end + return switch_map(input, map) end end diff --git a/test/tests.luau b/test/tests.luau index 254e955..7b068a6 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -6,6 +6,26 @@ local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 local vide = require "../../vide" + local root = vide.root + local mount = vide.mount + local create = vide.create + local source = vide.source + local effect = vide.effect + local derive = vide.derive + local switch = vide.switch + local show = vide.show + local indexes = vide.indexes + local values = vide.values + local cleanup = vide.cleanup + local untrack = vide.untrack + local read = vide.read + local batch = vide.batch + local context = vide.context + local spring = vide.spring + local action = vide.action + local changed = vide.changed + local apply = vide.apply + local step = vide.step local graph = require "../../vide/src/graph" type Node = graph.Node @@ -300,11 +320,6 @@ TEST("graph", function() end) TEST("mount()", function() - local mount = vide.mount - local create = vide.create - local source = vide.source - local cleanup = vide.cleanup - local screen = create "ScreenGui" {} local text = source "foo" @@ -334,9 +349,6 @@ TEST("mount()", function() end) TEST("root()", function() - local root = vide.root - local cleanup = vide.cleanup - local count = 0 root(function(destroy) @@ -348,9 +360,6 @@ TEST("root()", function() end) TEST("source()", wrap_root(function() - local source = vide.source - local effect = vide.effect - do CASE "create source" local src = source(1) CHECK(src() == 1) @@ -415,11 +424,6 @@ TEST("source()", wrap_root(function() end)) TEST("derive()", wrap_root(function() - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "derive new value on source change" local a = source(1) local b = source(2) @@ -507,7 +511,7 @@ TEST("derive()", wrap_root(function() local count = 0 local a = source(0) - local destroy = vide.mount(function() + local destroy = mount(function() local _b = derive(function() cleanup(function() count += 1 @@ -580,10 +584,6 @@ TEST("derive()", wrap_root(function() end)) TEST("effect()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local derive = vide.derive - do CASE "rerun on source change" local a = source(1) local b = source(1) @@ -636,15 +636,10 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() - local root = vide.root - local source = vide.source - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "root cleanup" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() cleanup(function() count += 1 end) @@ -717,10 +712,6 @@ TEST("cleanup()", wrap_root(function() end)) TEST("create()", wrap_root(function() - local create = vide.create - local source = vide.source - local cleanup = vide.cleanup - do CASE "create(\"ClassName\", props) syntax" local frame = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) CHECK(frame.BackgroundTransparency == 0.5) @@ -862,7 +853,7 @@ TEST("create()", wrap_root(function() do CASE "binding destroy" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() local src = source(0) return create "TextLabel" { @@ -928,7 +919,7 @@ TEST("create()", wrap_root(function() end do CASE "parent bound to source" - local _, wref, destroy = vide.root(function(destroy) + local _, wref, destroy = root(function(destroy) local frame = create "Frame" { Name = "Parent" } local parent = source(frame :: Frame?) @@ -955,7 +946,7 @@ TEST("create()", wrap_root(function() end do CASE "recursive create" - local set_test_to_true = vide.action(function(self) (self :: any).test = true end) + local set_test_to_true = action(function(self) (self :: any).test = true end) local f2 @@ -1087,13 +1078,6 @@ TEST("create()", wrap_root(function() end)) TEST("show()", wrap_root(function() - local untrack = vide.untrack - local cleanup = vide.cleanup - local source = vide.source - local effect = vide.effect - local show = vide.show - local root = vide.root - do CASE "show component" local input = source(true) local function one() return 1 end @@ -1139,7 +1123,6 @@ TEST("show()", wrap_root(function() local count = 0 show(input :: () -> number?, function(value: () -> number) - vide.cleanup(function() print "destroyed" end) effect(function() local v = value() @@ -1212,31 +1195,31 @@ TEST("show()", wrap_root(function() branch = 1 weapon { id = "1", enchant = "fire" } - CHECK(count == 8) + CHECK(count == 2) branch = 2 weapon { id = "1", enchant = "poison" } - CHECK(count == 10) + CHECK(count == 4) weapon { id = "1", enchant = nil } - CHECK(count == 10) + CHECK(count == 4) branch = 1 weapon { id = "1", enchant = "fire" } - CHECK(count == 14) + CHECK(count == 6) weapon(nil) branch = 2 weapon { id = "1", enchant = "poison" } - CHECK(count == 22) + CHECK(count == 8) vide.strict = false end do CASE "alt" -- todo: move test - local visible = vide.source(true) - local count = vide.source(0) + local visible = source(true) + local count = source(0) local outer = 0 local inner = 0 @@ -1280,14 +1263,161 @@ TEST("show()", wrap_root(function() CHECK(inner == 4) CHECK(destroyed == 3) end + + do CASE "delay (destruction)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end) + + CHECK(output() == nil) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + input(false) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5 + 0.01) + + CHECK(output() == nil) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(cleaned) + end + + do CASE "delay (reactivate before destruction)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end) + + CHECK(output() == nil) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + input(false) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + step(0.5 + 0.01) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + end + + do CASE "delay (with fallback)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local obj_fallback = {} + local present_fallback_upval + local cleaned_fallback = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end, function(present) + present_fallback_upval = present + cleanup(function() cleaned_fallback = true end) + CHECK(present() == false) + return obj_fallback, 1 + end) + + CHECK(output() == obj_fallback) + CHECK(value_upval == nil) + CHECK(present_upval == nil) + CHECK(not cleaned) + CHECK(present_fallback_upval() == true) + CHECK(not cleaned_fallback) + + input(true) + + CHECK(type(output() == "table") and table.find(output(), obj) and table.find(output(), obj_fallback)) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + CHECK(present_fallback_upval() == false) + CHECK(not cleaned_fallback) + + step(1 + 0.01) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + CHECK(present_fallback_upval() == false) + CHECK(cleaned_fallback) + end end)) TEST("switch()", wrap_root(function() - local source = vide.source - local switch = vide.switch - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "update on source change" local input = source(true) @@ -1315,28 +1445,27 @@ TEST("switch()", wrap_root(function() CHECK(output() == nil) end - do CASE "same component different map" - local input = source(0) + -- do CASE "same component different map" + -- local input = source(0) - local function component() - return {} - end + -- local function component() + -- return {} + -- end - local output = switch(input) { - [1] = component, - [2] = component - } + -- local output = switch(input) { + -- [1] = component, + -- [2] = component + -- } - CHECK(output() == nil) + -- CHECK(output() == nil) - input(1) - local instance = output() - CHECK(instance) + -- input(1) + -- local instance = output() + -- CHECK(instance) - input(2) - CHECK(output() == instance) - - end + -- input(2) + -- CHECK(output() == instance) + -- end do CASE "scoped switch" local input = source(true) @@ -1406,15 +1535,13 @@ TEST("switch()", wrap_root(function() vide.strict = false end + + do CASE "delay" + -- probably unneeded because show() uses switch() internally + end end)) TEST("indexes()", wrap_root(function() - local create = vide.create - local source = vide.source - local effect = vide.effect - local indexes = vide.indexes - local cleanup = vide.cleanup - do CASE "use source" local input = source { 1, 2, 3 } @@ -1432,7 +1559,7 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local _, output = vide.root(function() + local _, output = root(function() local output = indexes(input, function(v, i) count[i] += 1 return v @@ -1607,11 +1734,6 @@ TEST("indexes()", wrap_root(function() end)) TEST("values()", wrap_root(function() - local create = vide.create - local source = vide.source - local values = vide.values - local cleanup = vide.cleanup - do CASE "use source" local input = source { 1, 2, 3 } @@ -1733,11 +1855,6 @@ TEST("values()", wrap_root(function() end)) TEST("spring()", wrap_root(function() - local create = vide.create - local source = vide.source - local spring = vide.spring - local effect = vide.effect - do CASE "update source (on next step)" local value = source(10) local sprung = spring(value, 1, 1) @@ -1745,7 +1862,7 @@ TEST("spring()", wrap_root(function() CHECK(sprung() == 10) value(20) CHECK(sprung() == 10) - vide.step(1/60) + step(1/60) CHECK(sprung() ~= 10) CHECK(sprung() > 10) end @@ -1811,9 +1928,9 @@ TEST("spring()", wrap_root(function() local output = spring(input) input(1) - vide.step(0.05) + step(0.05) CHECK(output() ~= input()) -- check spring is moving - vide.step(10) -- spring finished, should be internally removed from queue + step(10) -- spring finished, should be internally removed from queue CHECK(output() == input()) -- check spring is at target local count = -1 @@ -1822,25 +1939,18 @@ TEST("spring()", wrap_root(function() count += 1 end) - vide.step(1) -- attempt to cause another spring update + step(1) -- attempt to cause another spring update CHECK(count == 0) -- check no update occurs as spring is finished -- gc() -- perform full gc input(2) -- spring should be re-added to spring queue - vide.step(0) -- process spring queue + step(0) -- process spring queue CHECK(count == 1) -- check spring was rescheduled correctly end end)) TEST("untrack()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local derive = vide.derive - local untrack = vide.untrack - local cleanup = vide.cleanup - local root = vide.root - do CASE "does not register dependency" local a = source(0) local b = source(0) @@ -1941,8 +2051,6 @@ TEST("untrack()", wrap_root(function() end)) TEST("events", function() - local create = vide.create - local function Thing(props) local instance = Instance.new("Thing") instance.Signal = Signal.new() @@ -1969,9 +2077,6 @@ TEST("events", function() end) TEST("actions", function() - local create = vide.create - local action = vide.action - do CASE "run action" local ran = false @@ -2005,11 +2110,6 @@ TEST("actions", function() end) TEST("changed()", wrap_root(function() - local root = vide.root - local create = vide.create - local source = vide.source - local changed = vide.changed - do CASE "outputs" local output = source(nil) @@ -2044,11 +2144,6 @@ TEST("changed()", wrap_root(function() end)) TEST("batch()", wrap_root(function() - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local batch = vide.batch - do CASE "evaluation deferred" local a = source(0) @@ -2332,10 +2427,6 @@ TEST("batch()", wrap_root(function() end)) TEST("read()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local read = vide.read :: any -- todo - do CASE "read primitive" CHECK(read(1) == 1) end @@ -2360,12 +2451,6 @@ TEST("read()", wrap_root(function() end)) TEST("context()", function() - local root = vide.root - local context = vide.context - local effect = vide.effect - local untrack = vide.untrack - local show = vide.show - do CASE "set context" local ctx = context() @@ -2464,13 +2549,6 @@ TEST("context()", function() end) TEST("nested effects cases", function() - local vide = require "../../vide" - local source = vide.source - local effect = vide.effect - local untrack = vide.untrack - local cleanup = vide.cleanup - local root = vide.root - local ran = 0 local cleaned = 0 @@ -2512,11 +2590,6 @@ TEST("nested effects cases", function() end) TEST("graph edge cases", wrap_root(function() - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local root = vide.root - do CASE "diamond A,B,C,D" --[[ @@ -2687,15 +2760,6 @@ end)) TEST("strict", wrap_root(function() vide.strict = true - local root = vide.root - local show = vide.show - local create = vide.create - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local indexes, values = vide.indexes, vide.values - local untrack = vide.untrack - do CASE "error on derived callback yield" local src = source(1) @@ -2832,7 +2896,7 @@ TEST("strict", wrap_root(function() root(function() show(src, function() src(false) - vide.cleanup(function() count += 1 end) + cleanup(function() count += 1 end) return {} end) end) @@ -2841,6 +2905,7 @@ TEST("strict", wrap_root(function() src(true) end) + CHECK(count == 0) CHECK(not ok) end @@ -2854,9 +2919,9 @@ TEST("strict", wrap_root(function() effect(function() untrack(function() indexes(src, function() - vide.cleanup(function() count_1 += 1 end) + cleanup(function() count_1 += 1 end) src {} - vide.cleanup(function() count_2 += 1 end) + cleanup(function() count_2 += 1 end) return {} end) end) @@ -2880,9 +2945,9 @@ TEST("strict", wrap_root(function() effect(function() untrack(function() values(src, function() - vide.cleanup(function() count_1 += 1 end) + cleanup(function() count_1 += 1 end) src {} - vide.cleanup(function() count_2 += 1 end) + cleanup(function() count_2 += 1 end) return {} end) end) From 4b4db9602e0f6e53280525704b197af8c030d6c7 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:37:46 +0100 Subject: [PATCH 74/94] Implement delays for `indexes()` and `values()` --- src/indexes.luau | 178 +++++++++++++++++++++--------------------- src/switch.luau | 5 +- src/values.luau | 179 ++++++++++++++++++++----------------------- test/benchmarks.luau | 2 +- test/tests.luau | 156 ++++++++++++++++++++++++++++++++++++- 5 files changed, 332 insertions(+), 188 deletions(-) diff --git a/src/indexes.luau b/src/indexes.luau index acad4c0..d13d1c9 100644 --- a/src/indexes.luau +++ b/src/indexes.luau @@ -1,114 +1,118 @@ local flags = require "./flags" -local graph = require "./graph" -type Node = graph.Node -type SourceNode = graph.SourceNode -local create_node = graph.create_node -local create_source_node = graph.create_source_node -local push_scope_as_child_of = graph.push_scope_as_child_of -local update_descendants = graph.update_descendants -local assert_stable_scope = graph.assert_stable_scope -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope -local evaluate_node = graph.evaluate_node -local destroy = graph.destroy +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () +type Array = { T } type Map = { [K]: V } +type Source = () -> T -local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO, delay: number?): () -> { VO } - local owner = assert_stable_scope() - local subowner = create_node(owner, false, false) +local function indexes( + input: Source>, + component: (Source, K, Source) -> (Obj, number?) +): Source> + local update_count = 0 + local caches = {} :: Map (), + present: (boolean?) -> boolean, + value: V?, + value_source: (V?) -> V, + object: Obj, + delay: number, + timeout: { cancel: boolean }?, + count: number, + }> - local input_cache = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local scopes = {} :: Map> - - local function update_children(data) - local children_need_update = false - - -- remove old indexes - for i in input_cache do - if data[i] == nil then - destroy(scopes[i]) - input_cache[i] = nil - output_cache[i] = nil - input_nodes[i] = nil - scopes[i] = nil - children_need_update = true - end + local output = source({} :: Array) + local function update_output() + local array = table.create(#caches) + for _, cache in caches do + table.insert(array, cache.object) end + output(array) + end - push_scope(subowner) + effect(function() + local data = input() - -- process new or changed values + local count = update_count + update_count += 1 + + local children_need_update = false -- set to true if a scope is created or destroyed + + -- process data for i, v in data do - local cv = input_cache[i] + local cache = caches[i] - if cv ~= v then - input_cache[i] = v + if cache == nil then -- create new scope and create component + local value_source = source(v) + local present = source(false) - if cv == nil then -- create new scope and run transform - local scope = create_node(subowner, false, false) - scopes[i] = scope :: Node + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(value_source, i, present) + delay = t + return object + end) - local node = create_source_node(v) + present(true) + + children_need_update = true - push_scope(scope) + caches[i] = { + count = count, + value = v, + destroy_scope = destroy, + value_source = value_source, + present = present, + delay = delay or 0, + object = object + } + else -- update source + cache.count = count - local ok, result = xpcall(transform, debug.traceback, function() - push_scope_as_child_of(node) - return node.cache - end, i) - - pop_scope() - - if not ok then - pop_scope() -- subowner scope - error(result, 0) + if cache.value ~= v then + if cache.timeout then + cache.timeout.cancel = true + cache.timeout = nil + cache.present(true) end - input_nodes[i] = node - output_cache[i] = result - children_need_update = true - else -- update source - input_nodes[i].cache = v - update_descendants(input_nodes[i]) + cache.value = v + cache.value_source(v) end end end - pop_scope() + -- remove old indexes + for i, cache in caches do + if cache.count < count then -- if count is not latest then value is no longer in the input table + cache.present(false) + + if cache.delay == 0 then + cache.destroy_scope() + caches[i] = nil + children_need_update = true + else + cache.value = nil + if cache.timeout == nil then + cache.timeout = timeout(cache.delay, function() -- todo: avoid redundant updates (e.g. indexes() input is cleared) + cache.destroy_scope() + caches[i] = nil + update_output() + end) + end + end + end + end if children_need_update then - local output_array_size = #output_cache - local output_array - - -- check if the table contains a dictionary section - if output_array_size > 0 and next(output_cache, output_array_size) == nil then - output_array = table.clone(output_cache) - else - output_array = table.create(output_array_size) - for _, v in output_cache do - table.insert(output_array, v) - end - end - - return output_array - else - return nil + update_output() end - end + end) - local node = create_node(owner, function(pre_children) - return update_children(input()) or pre_children - end, {}) - - evaluate_node(node) - - return function() - push_scope_as_child_of(node) - return node.cache - end + return output end return indexes diff --git a/src/switch.luau b/src/switch.luau index b770d26..38a7586 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -8,7 +8,10 @@ type Map = { [K]: V } type Source = () -> T type Component = (Source) -> T -local function switch_map(input: Source, map: Map>): Source> +local function switch_map( + input: Source, + map: Map> +): Source> local output = source(nil :: nil | Obj | Array) local caches = {} :: Map = graph.Node -type SourceNode = graph.SourceNode -local create_node = graph.create_node -local create_source_node = graph.create_source_node -local push_scope_as_child_of = graph.push_scope_as_child_of -local update_descendants = graph.update_descendants -local assert_stable_scope = graph.assert_stable_scope -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope -local evaluate_node = graph.evaluate_node -local destroy = graph.destroy +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () +type Array = { T } type Map = { [K]: V } +type Source = () -> T -local function check_primitives(t: {}) - if not flags.strict then return end - - for _, v in t do - if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end - error("table source map cannot return primitives", 0) - end -end - -local function values(input: () -> Map, transform: (VI, () -> K) -> VO, delay: number?): () -> { VO } - local owner = assert_stable_scope() - local subowner = create_node(owner, false, false) - +local function values( + input: Source>, + component: (V, Source, Source) -> (Obj, number?) +): Source> local update_count = 0 - - local caches = {} :: Map (), + present: (boolean?) -> boolean, + index: K?, + index_source: (K?) -> K, + object: Obj, + delay: number, + timeout: { cancel: boolean }?, count: number, - index: K, - scope: Node, - index_source: SourceNode, - alive_source: SourceNode, - result: VO, }> - local function update_children(data: Map) + local output = source({} :: Array) + local function update_output() + local array = table.create(16) + for _, cache in caches do + table.insert(array, cache.object) + end + output(array) + end + + effect(function() + local data = input() + local count = update_count update_count += 1 - local children_need_update = false + local children_need_update = false -- set to true if a scope is created or destroyed if flags.strict then - local cache = {} + local map = {} for _, v in data do - if cache[v] ~= nil then - error "duplicate table value detected" + if map[v] then + error("table source passed to `values()` contains duplicate values", 0) end - cache[v] = true + map[v] = true end end - push_scope(subowner) - -- process data for i, v in data do local cache = caches[v] - if cache == nil then -- create new scope and run transform - local scope = create_node(subowner, false, false) - local index_source = create_source_node(i) + if cache == nil then -- create new scope and create component + local index_source = source(i) + local present = source(false) - local new_cache = { + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(v, index_source, present) + delay = t + return object + end) + + present(true) + + children_need_update = true + + caches[v] = { count = count, index = i, - scope = scope :: Node, - index_source = index_source :: SourceNode, - alive_source = nil :: any, - result = false :: any, + destroy_scope = destroy, + index_source = index_source, + present = present, + delay = delay or 0, + object = object } - -- must be set before transform is run so that the scope can - -- be destroyed if the transform itself updates the input - -- which lets the strict active scope destruction check work - caches[v] = new_cache - - push_scope(scope) - - local ok, result = xpcall(transform, debug.traceback, v, function() - push_scope_as_child_of(index_source) - return index_source.cache - end) - - pop_scope() - - if not ok then - pop_scope() -- subowner scope - error(result, 0) - end - - new_cache.result = result - children_need_update = true else -- update source cache.count = count if cache.index ~= i then + if cache.timeout then + cache.timeout.cancel = true + cache.timeout = nil + cache.present(true) + end + cache.index = i - cache.index_source.cache = i - update_descendants(cache.index_source) + cache.index_source(i) end end end - pop_scope() - -- remove old values for v, cache in caches do - if cache.count < count then - destroy(cache.scope) - caches[v] = nil - children_need_update = true + if cache.count < count then -- if count is not latest then value is no longer in the input table + cache.present(false) + + if cache.delay == 0 then + cache.destroy_scope() + caches[v] = nil + children_need_update = true + else + cache.index = nil + if cache.timeout == nil then + cache.timeout = timeout(cache.delay, function() -- todo: avoid redundant updates (e.g. values() input is cleared) + cache.destroy_scope() + caches[v] = nil + update_output() + end) + end + end end end if children_need_update then - local output_array = table.create(#data) - for _, cache in caches do - table.insert(output_array, cache.result) - end - check_primitives(output_array) - - return output_array - else - return nil + update_output() end - end + end) - local node = create_node(owner, function(pre_children) -- todo: add test - return update_children(input()) or pre_children - end, {}) - - evaluate_node(node) - - return function() - push_scope_as_child_of(node) - return node.cache - end + return output end return values diff --git a/test/benchmarks.luau b/test/benchmarks.luau index 690c8b9..cbb55f9 100644 --- a/test/benchmarks.luau +++ b/test/benchmarks.luau @@ -27,7 +27,7 @@ local function ROOT_BENCH(name: string, fn: () -> ()) end)() end -local N = 2^18 -- 262144 +local N = 2^20 TITLE "sources" diff --git a/test/tests.luau b/test/tests.luau index 7b068a6..1df61e9 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1625,9 +1625,12 @@ TEST("indexes()", wrap_root(function() do -- check that `input` allows gc of `output` local input = source {} - local output = indexes(input, function(v, i) - return v, i + local destroy, output = root(function() + return indexes(input, function(v, i) + return v, i + end) end) + destroy() local wref = weak { output } @@ -1731,6 +1734,81 @@ TEST("indexes()", wrap_root(function() vide.strict = false end + + do CASE "delay" + local input = source {} + + local cleaned_counts = {} :: Map + + local output = indexes(input, function(v, i, present) + cleanup(function() + cleaned_counts[i] = (cleaned_counts[i] or 0) + 1 + end) + return { value = v, index = i, present = present }, 1 + end) + + local function mapped() + local map = {} + local objects = output() + if objects then + for _, object in objects do + map[object.index] = { value = object.value, present = object.present } + end + end + return map + end + + ------------------------------------------------------------------------ + + do + CHECK(mapped()[1] == nil) + end + + input { 1, 2 } + + do + CHECK(mapped()[1].value() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(mapped()[2].present()) + end + + input { 2 } + step(0.5) + + do + CHECK(mapped()[1].value() == 2) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(not mapped()[2].present()) + end + + input { 1, 2 } + step(0.5 + 0.01) + + do + CHECK(mapped()[1].value() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(mapped()[2].present()) + end + + input { 3 } + step(1 + 0.01) + + do + CHECK(mapped()[1].value() == 3) + CHECK(mapped()[1].present()) + + CHECK(not mapped()[2]) + + CHECK(cleaned_counts[1] == nil) + CHECK(cleaned_counts[2] == 1) + end + end end)) TEST("values()", wrap_root(function() @@ -1852,6 +1930,80 @@ TEST("values()", wrap_root(function() CHECK(n0 == n1) end + + do CASE "delay" + local input = source {} + + local cleaned_counts = {} :: Map + + local output = values(input, function(v, i, present) + cleanup(function() + cleaned_counts[v] = (cleaned_counts[v] or 0) + 1 + end) + return { value = v, index = i, present = present }, 1 + end) + + local function mapped() + local map = {} + local objects = output() + if objects then + for _, object in objects do + map[object.value] = { index = object.index, present = object.present } + end + end + return map + end + + ------------------------------------------------------------------------ + + do + CHECK(mapped()[1] == nil) + end + + input { 1 } + + do + CHECK(mapped()[1].index() == 1) + CHECK(mapped()[1].present()) + end + + input { 2 } + step(0.5) + + do + CHECK(mapped()[1].index() == 1) + CHECK(not mapped()[1].present()) + + CHECK(mapped()[2].index() == 1) + CHECK(mapped()[2].present()) + end + + input { 1, 2 } + step(0.5 + 0.01) + + do + CHECK(mapped()[1].index() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].index() == 2) + CHECK(mapped()[2].present()) + end + + input { 3 } + step(1 + 0.01) + + do + CHECK(not mapped()[1]) + CHECK(not mapped()[2]) + + CHECK(mapped()[3].index() == 1) + CHECK(mapped()[3].present()) + + CHECK(cleaned_counts[1] == 1) + CHECK(cleaned_counts[2] == 1) + CHECK(cleaned_counts[3] == nil) + end + end end)) TEST("spring()", wrap_root(function() From ce24f8ade9b2b8d40fc55e7c943253a54fb46afa Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 1 Sep 2025 21:59:35 +0100 Subject: [PATCH 75/94] Cleanup scope functions --- src/indexes.luau | 81 +++++++++++++++++++++++----------------------- src/switch.luau | 55 +++++++++++++++++--------------- src/values.luau | 83 +++++++++++++++++++++++++----------------------- test/tests.luau | 20 ++++++++++++ 4 files changed, 134 insertions(+), 105 deletions(-) diff --git a/src/indexes.luau b/src/indexes.luau index d13d1c9..aa533f4 100644 --- a/src/indexes.luau +++ b/src/indexes.luau @@ -13,24 +13,26 @@ local function indexes( component: (Source, K, Source) -> (Obj, number?) ): Source> local update_count = 0 - local caches = {} :: Map (), - present: (boolean?) -> boolean, + local scopes = {} :: Map (), + object: Obj, value: V?, value_source: (V?) -> V, - object: Obj, - delay: number, - timeout: { cancel: boolean }?, count: number, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }?, }> local output = source({} :: Array) local function update_output() - local array = table.create(#caches) - for _, cache in caches do - table.insert(array, cache.object) + local objects = table.create(4) + + for _, scope in scopes do + table.insert(objects, scope.object) end - output(array) + + output(objects) end effect(function() @@ -41,11 +43,11 @@ local function indexes( local children_need_update = false -- set to true if a scope is created or destroyed - -- process data + -- create or update scopes for i, v in data do - local cache = caches[i] + local scope = scopes[i] - if cache == nil then -- create new scope and create component + if scope == nil then -- create new scope and create component local value_source = source(v) local present = source(false) @@ -60,46 +62,47 @@ local function indexes( children_need_update = true - caches[i] = { - count = count, + scopes[i] = { + destroy = destroy, + object = object, value = v, - destroy_scope = destroy, value_source = value_source, - present = present, + count = count, delay = delay or 0, - object = object + present = present, + timeout = nil, } - else -- update source - cache.count = count + else -- update scope + scope.count = count - if cache.value ~= v then - if cache.timeout then - cache.timeout.cancel = true - cache.timeout = nil - cache.present(true) + if scope.value ~= v then + if scope.timeout then -- index is in input table again; cancel destruction + scope.timeout.cancel = true + scope.timeout = nil + scope.present(true) end - cache.value = v - cache.value_source(v) + scope.value = v + scope.value_source(v) end end end - -- remove old indexes - for i, cache in caches do - if cache.count < count then -- if count is not latest then value is no longer in the input table - cache.present(false) + -- destroy scopes + for i, scope in scopes do + if scope.count < count then -- if count is not latest then index is no longer in the input table + scope.present(false) - if cache.delay == 0 then - cache.destroy_scope() - caches[i] = nil + if scope.delay == 0 then + scope.destroy() + scopes[i] = nil children_need_update = true else - cache.value = nil - if cache.timeout == nil then - cache.timeout = timeout(cache.delay, function() -- todo: avoid redundant updates (e.g. indexes() input is cleared) - cache.destroy_scope() - caches[i] = nil + scope.value = nil -- set to nil for the `scope.value ~= v` check + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() -- todo: possible redundant updates + scope.destroy() + scopes[i] = nil update_output() end) end diff --git a/src/switch.luau b/src/switch.luau index 38a7586..ee1af28 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -6,26 +6,26 @@ local timeout = require "./timeout" () type Array = { T } type Map = { [K]: V } type Source = () -> T -type Component = (Source) -> T +type Component = (Source) -> (T, number?) local function switch_map( input: Source, map: Map> ): Source> - local output = source(nil :: nil | Obj | Array) - - local caches = {} :: Map (), - present: (boolean?) -> boolean, + local scopes = {} :: Map (), object: Obj, delay: number, + present: (boolean?) -> boolean, timeout: { cancel: boolean }? }> + local output = source(nil :: nil | Obj | Array) local function update_output() local objects = {} - for _, cache in caches do - table.insert(objects, cache.object) + + for _, scope in scopes do + table.insert(objects, scope.object) end output( @@ -38,33 +38,36 @@ local function switch_map( effect(function() local key: K? = input() - for k, cache in caches do + -- destroy (or queue destroy) all scopes not associated with the input key + for k, scope in scopes do if k == key then continue end - cache.present(false) - if cache.delay == 0 then - cache.destroy_scope() - caches[k] = nil + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[k] = nil else - if cache.timeout == nil then - cache.timeout = timeout(cache.delay, function() - cache.destroy_scope() - caches[k] = nil + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() + scope.destroy() + scopes[k] = nil update_output() end) end end end + -- create new scope or abort destruction of existing scope if key exists if key ~= nil then - local cache = caches[key] + local scope = scopes[key] - if cache then - cache.present(true) + if scope then + scope.present(true) - if cache.timeout then - cache.timeout.cancel = true - cache.timeout = nil + if scope.timeout then + scope.timeout.cancel = true + scope.timeout = nil end else local component = map[key] @@ -85,11 +88,11 @@ local function switch_map( present(true) - caches[key] = { - destroy_scope = destroy, - present = present, + scopes[key] = { + destroy = destroy, object = object, delay = delay or 0, + present = present, timeout = nil } end diff --git a/src/values.luau b/src/values.luau index b9cd7a8..c4a3563 100644 --- a/src/values.luau +++ b/src/values.luau @@ -13,24 +13,26 @@ local function values( component: (V, Source, Source) -> (Obj, number?) ): Source> local update_count = 0 - local caches = {} :: Map (), - present: (boolean?) -> boolean, + local scopes = {} :: Map (), + object: Obj, index: K?, index_source: (K?) -> K, - object: Obj, - delay: number, - timeout: { cancel: boolean }?, count: number, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }?, }> local output = source({} :: Array) local function update_output() - local array = table.create(16) - for _, cache in caches do - table.insert(array, cache.object) + local objects = table.create(4) + + for _, scope in scopes do + table.insert(objects, scope.object) end - output(array) + + output(objects) end effect(function() @@ -41,7 +43,7 @@ local function values( local children_need_update = false -- set to true if a scope is created or destroyed - if flags.strict then + if flags.strict then -- check for duplicate values local map = {} for _, v in data do if map[v] then @@ -51,11 +53,11 @@ local function values( end end - -- process data + -- create or update scopes for i, v in data do - local cache = caches[v] + local scope = scopes[v] - if cache == nil then -- create new scope and create component + if scope == nil then -- create new scope and create component local index_source = source(i) local present = source(false) @@ -70,46 +72,47 @@ local function values( children_need_update = true - caches[v] = { - count = count, + scopes[v] = { + destroy = destroy, + object = object, index = i, - destroy_scope = destroy, index_source = index_source, - present = present, + count = count, delay = delay or 0, - object = object + present = present, + timeout = nil, } - else -- update source - cache.count = count + else -- update scope + scope.count = count - if cache.index ~= i then - if cache.timeout then - cache.timeout.cancel = true - cache.timeout = nil - cache.present(true) + if scope.index ~= i then + if scope.timeout then -- value is in input table again; cancel destruction + scope.timeout.cancel = true + scope.timeout = nil + scope.present(true) end - cache.index = i - cache.index_source(i) + scope.index = i + scope.index_source(i) end end end - -- remove old values - for v, cache in caches do - if cache.count < count then -- if count is not latest then value is no longer in the input table - cache.present(false) + -- destroy scopes + for v, scope in scopes do + if scope.count < count then -- if count is not latest then value is no longer in the input table + scope.present(false) - if cache.delay == 0 then - cache.destroy_scope() - caches[v] = nil + if scope.delay == 0 then + scope.destroy() + scopes[v] = nil children_need_update = true else - cache.index = nil - if cache.timeout == nil then - cache.timeout = timeout(cache.delay, function() -- todo: avoid redundant updates (e.g. values() input is cleared) - cache.destroy_scope() - caches[v] = nil + scope.index = nil -- set to nil for the `scope.index ~= i` check + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() -- todo: possible redundant updates + scope.destroy() + scopes[v] = nil update_output() end) end diff --git a/test/tests.luau b/test/tests.luau index 1df61e9..9a2c73f 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2004,6 +2004,26 @@ TEST("values()", wrap_root(function() CHECK(cleaned_counts[3] == nil) end end + + do CASE "delayed destruction deferred" + local input = source {} + local output = values(input, function() + return {}, 1 + end) + + local count = 0 + effect(function() output(); count += 1 end) + + input { 1, 2, 3 } + CHECK(count == 2) + + input {} + CHECK(count == 2) + + step(1 + 0.01) + --CHECK(count == 3) + CHECK(count == 5) + end end)) TEST("spring()", wrap_root(function() From 945cdd0e88c6235f14170cb4515d326e74256517 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:09:06 +0100 Subject: [PATCH 76/94] Update changelog --- CHANGELOG.md | 1 + src/implicit_effect.luau | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3b525..01f2dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). of the condition. - Ignore `false` passed as a child. - Flag `vide.defaults` to disable the setting of default properties. +- Delayed scope destruction for control flow functions: `show()` `switch()` `indexes()` `values()`. ### Changed diff --git a/src/implicit_effect.luau b/src/implicit_effect.luau index 5332662..7dbd2f7 100644 --- a/src/implicit_effect.luau +++ b/src/implicit_effect.luau @@ -23,6 +23,7 @@ local function update_parent_effect(p: { return p end +-- todo: investigate if "count" method used in indexes() and values() can improve performance here local function update_children_effect(p: { instance: Instance, cur_children_set: { [Instance]: true }, @@ -39,6 +40,7 @@ local function update_children_effect(p: { if new_children_set[child] then return end -- stops redundant reparenting new_children_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 else @@ -85,7 +87,9 @@ return { property = property, source = source }) + evaluate_node(node) + return node end, @@ -94,7 +98,9 @@ return { instance = instance, source = parent }) + evaluate_node(node) + return node end, From bc2bb25f7411f266f9018a5a17d95a3f29d2e693 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:19:24 +0100 Subject: [PATCH 77/94] Add `vide.default` type --- src/flags.luau | 3 +-- src/lib.luau | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flags.luau b/src/flags.luau index c4989d9..be48232 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -6,8 +6,7 @@ local is_O2 = inline_test() ~= "inline_test" return { strict = not is_O2, - batch = false, defaults = true, - use_default_properties = true, defer_nested_properties = true, + batch = false, } diff --git a/src/lib.luau b/src/lib.luau index 74a2333..3312f26 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -78,6 +78,7 @@ local vide = { -- flags strict = (nil :: any) :: boolean, + defaults = (nil :: any) :: boolean, defer_nested_properties = (nil :: any) :: boolean, -- temporary From a4690943288105d3c63b992a87c73c8d648b5950 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:22:09 +0100 Subject: [PATCH 78/94] Do not auto deploy site on push to main --- .github/workflows/deploy.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 74258d7..5bb1717 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,6 @@ name: site-deploy on: - push: - branches: [main] # todo: remove later - workflow_dispatch: permissions: From 9ace23470f6dd151d7e532c989ebc4cd40b33c92 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Tue, 2 Sep 2025 03:12:19 +0100 Subject: [PATCH 79/94] Use generalized iteration everywhere Closes #55 --- src/apply.luau | 6 +++--- src/graph.luau | 2 +- src/implicit_effect.luau | 4 ++-- src/spring.luau | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/apply.luau b/src/apply.luau index 85020ff..c05fedf 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -133,8 +133,8 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown ;(instance :: any)[event_name]:Connect(event_listener) end - for _, queued in next, actions do - for _, callback in next, queued do + for _, queued in actions do + for _, callback in queued do callback(instance) end end @@ -149,7 +149,7 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown end table.clear(events) - for _, queued in next, actions do table.clear(queued) end + for _, queued in actions do table.clear(queued) end caches.parent = nil if flags.strict then table.clear(nested_debug) end table.clear(nested_stack) diff --git a/src/graph.luau b/src/graph.luau index 4834455..fd22489 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -93,7 +93,7 @@ end local function flush_cleanups(node: Node) if node.cleanups then - for _, fn in next, node.cleanups do + for _, fn in node.cleanups do local ok, err: string? = xpcall(fn, debug.traceback) if not ok then error(`cleanup error: {err}`, 0) end end diff --git a/src/implicit_effect.luau b/src/implicit_effect.luau index 7dbd2f7..6fed9bc 100644 --- a/src/implicit_effect.luau +++ b/src/implicit_effect.luau @@ -47,7 +47,7 @@ local function update_children_effect(p: { cur_children_set[child] = nil -- remove child from cache if it was already in cache end elseif type(child) == "table" then - for _, child in next, child do + for _, child in child do process_child(child) end elseif type(child) == "function" then @@ -70,7 +70,7 @@ local function update_children_effect(p: { process_child(new_children) - for child in next, cur_children_set do + for child in cur_children_set do child.Parent = nil -- unparent all children that weren't in the new children set end diff --git a/src/spring.luau b/src/spring.luau index 6e1ce1e..303ed74 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -233,7 +233,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number end local function step_springs(dt: number) - for data in next, springs do + for data in springs do local k, c, x0_123, x1_123, u_123, x0_456, x1_456, u_456 = From fb10fc2b0d7ca7d51a8a1312d80a4a6626af8bc1 Mon Sep 17 00:00:00 2001 From: ernisto Date: Fri, 24 Oct 2025 20:00:46 -0300 Subject: [PATCH 80/94] fix(spring tolerance): guarantee a minimun movement to goal --- src/spring.luau | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index 303ed74..2224894 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -9,8 +9,6 @@ local update_descendants = graph.update_descendants local push_scope_as_child_of = graph.push_scope_as_child_of local UPDATE_RATE = 120 -local TOLERANCE = 0.001 -local TOLERANCE_VECTOR = vector.create(TOLERANCE, TOLERANCE, TOLERANCE) type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 @@ -232,6 +230,24 @@ local function spring(source: () -> T, period: number?, damping_ratio: number end, config end +-- luau vectors have f32 for each component, unlike luau number which is f64 +local FLOAT32_MANTISSA_BITS = 23 +type float32 = number + +local function get_min_step(x: float32) + local _,exponent = math.frexp(x) + + local lower_mantissa = math.ldexp(1, -FLOAT32_MANTISSA_BITS - 1) + return math.ldexp(lower_mantissa, exponent) +end +local function get_min_vector_step(goal: vector): vector + return vector.create( + get_min_step(goal.x), + get_min_step(goal.y), + get_min_step(goal.z) + ) +end + local function step_springs(dt: number) for data in springs do local k, c, @@ -261,9 +277,13 @@ local function step_springs(dt: number) local v_123 = u_123 + dv_123 local v_456 = u_456 + dv_456 + -- guarantee pos < pos + velocity < goal + local a_123 = vector.max(get_min_vector_step(x0_123), vector.abs(v_123) * dt) * vector.sign(v_123) + local a_456 = vector.max(get_min_vector_step(x0_456), vector.abs(v_456) * dt) * vector.sign(v_456) + -- calculate new position - local x_123 = x0_123 + v_123*dt - local x_456 = x0_456 + v_456*dt + local x_123 = x0_123 + a_123 + local x_456 = x0_456 + a_456 data.x0_123, data.x0_456 = x_123, x_456 data.v_123, data.v_456 = v_123, v_456 @@ -272,21 +292,8 @@ end local function update_spring_sources() for data, output in springs do - local x0_123, x1_123, v_123, - x0_456, x1_456, v_456 = - data.x0_123, data.x1_123, data.v_123, - data.x0_456, data.x1_456, data.v_456 - - local max_difference = vector.max( - vector.abs(x0_123 - x1_123 :: any), - vector.abs(x0_456 - x1_456 :: any), - vector.abs(v_123 :: any), - vector.abs(v_456 :: any), - TOLERANCE_VECTOR - ) - - if max_difference == TOLERANCE_VECTOR then - -- close enough to target, unshedule spring and set value to target + local x0_123, x0_456 = data.x0_123, data.x0_456 + if x0_123 == data.x1_123 and x0_456 == data.x1_456 then springs[data] = nil output.cache = data.source_value else From 800396fa0ee1c395e2bd2ce23209e948f8d2dda8 Mon Sep 17 00:00:00 2001 From: ernisto Date: Fri, 24 Oct 2025 22:58:12 -0300 Subject: [PATCH 81/94] feat(spring): handle goal reached before update spring sources --- src/spring.luau | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spring.luau b/src/spring.luau index 2224894..89ba34c 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -257,6 +257,8 @@ local function step_springs(dt: number) data.x0_123, data.x1_123, data.v_123, data.x0_456, data.x1_456, data.v_456 + if x0_123 == x1_123 and x0_456 == x1_456 then continue end + -- calculate displacement from target local dx_123 = x0_123 - x1_123 local dx_456 = x0_456 - x1_456 @@ -277,7 +279,7 @@ local function step_springs(dt: number) local v_123 = u_123 + dv_123 local v_456 = u_456 + dv_456 - -- guarantee pos < pos + velocity < goal + -- guarantee pos < pos + velocity <= goal local a_123 = vector.max(get_min_vector_step(x0_123), vector.abs(v_123) * dt) * vector.sign(v_123) local a_456 = vector.max(get_min_vector_step(x0_456), vector.abs(v_456) * dt) * vector.sign(v_456) From c16022586e80d679c9e93d787468a585600cb889 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:51:09 +0000 Subject: [PATCH 82/94] Add create type test --- test/create-types.luau | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 test/create-types.luau diff --git a/test/create-types.luau b/test/create-types.luau new file mode 100644 index 0000000..3d387e3 --- /dev/null +++ b/test/create-types.luau @@ -0,0 +1,70 @@ +type Pseudo = { + Test: number +} + +type Instances = { + TextLabel: TextLabel, + TextButton: TextButton, + ImageLabel: ImageLabel, + Pseudo: Pseudo +} + +type function Properties(instance: type?) + local properties = types.newtable() + + while instance do + for i, v in instance:properties() do + local connector = v.read and v.read.tag == "table" and v.read:readproperty(types.singleton("Connect")) + if connector then + if not connector then continue end + local params = connector:parameters().head + if not params then continue end + local listener = params[2] + if not listener then continue end + properties:setproperty(i, types.optional(listener)) + elseif v.write then + properties:setproperty(i, types.optional(types.unionof( + v.write, + types.newfunction({}, { head = { v.write } }) + ))) + end + end + + instance = instance:readparent() + end + + properties:setindexer(types.number, types.any) + + return properties +end + +local function create(name: Name | keyof): (Properties>) -> index + return function() + return "" :: any + end +end + +local vide = require "../src/" +local count = vide.source(0) +create("TextButton") { + BackgroundTransparency = 1, + AnchorPoint = "bad value", -- should error + InvalidProperty = true, -- should error + + Text = function() + return "count: " .. count() + end, + + Size = function() -- should error + return "bad value" + end, + + MouseEnter = function(x, y) + + end, + + Activated = "bad value", -- should error + + create "TextLabel" {}, + function() end, +} From d7d2f5167e7c267dc9932d5c815536bf247bd5e9 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:35:34 +0000 Subject: [PATCH 83/94] Use type functions for `vide.create` --- src/create.luau | 158 ++++++++++++++++++++++------------------- test/create-types.luau | 2 +- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/src/create.luau b/src/create.luau index 193aa0f..6273b80 100644 --- a/src/create.luau +++ b/src/create.luau @@ -5,90 +5,104 @@ local defaults = require "./defaults" local apply = require "./apply" local flags = require "./flags" -local ctor_cache = {} :: { [string]: () -> Instance } +local ctor_cache = {} :: { [string]: (AnyProps) -> Instance } +local function lazy_init(_, class: string) + local function ctor(properties: AnyProps): Instance + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then error(`invalid class name { class }`, 0) end -setmetatable(ctor_cache :: any, { - __index = function(self, class) - local function ctor(properties: Props): Instance - local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then error(`invalid class name { class }`, 0) end - - if flags.defaults then - local default: { [string]: unknown }? = defaults[class] - if default then - for i, v in default do - (instance :: any)[i] = v - end + if flags.defaults then + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in default do + (instance :: any)[i] = v end end - - return apply(instance, properties) end - self[class] = ctor - return ctor + return apply(instance, properties) end -}) -local function create_instance(class: string) - return ctor_cache[class] + ctor_cache[class] = ctor + return ctor end +setmetatable(ctor_cache, { __index = lazy_init }) -local function clone_instance(instance: Instance) - return function(properties: Props): Instance - local clone = instance:Clone() - if not clone then error "attempt to clone a non-archivable instance" end - return apply(clone, properties) +-- todo: remove support for different overloads +local function create(class_or_instance: string|Instance, properties: AnyProps?): ((AnyProps) -> Instance) | Instance + if type(class_or_instance) ~= "string" and typeof(class_or_instance) ~= "Instance" then + error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0) end + + local ctor = if type(class_or_instance) == "string" + then ctor_cache[class_or_instance] + else function(properties) + local clone = class_or_instance:Clone() + if not clone then error "attempt to clone a non-archivable instance" end + return apply(clone, properties) + end + + return if properties + then ctor(properties) + else ctor end -local function create(class_or_instance: string | Instance, props: Props?): ((Props) -> Instance) | Instance - local result: (Props) -> Instance - if type(class_or_instance) == "string" then - result = create_instance(class_or_instance) - elseif typeof(class_or_instance) == "Instance" then - result = clone_instance(class_or_instance) - else - error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0) - return nil :: never - end - if props then - return result(props) - end - return result +type Instances = { + Folder: Folder, + BillboardGui: BillboardGui, + CanvasGroup: CanvasGroup, + Frame: Frame, + ImageButton: ImageButton, + ImageLabel: ImageLabel, + ScreenGui: ScreenGui, + ScrollingFrame: ScrollingFrame, + SurfaceGui: SurfaceGui, + TextBox: TextBox, + TextButton: TextButton, + TextLabel: TextLabel, + UIAspectRatioConstraint: UIAspectRatioConstraint, + UICorner: UICorner, + UIGradient: UIGradient, + UIGridLayout: UIGridLayout, + UIListLayout: UIListLayout, +} + +type function Properties(instance: type?) + local properties = types.newtable() + + while instance do + for i, v in instance:properties() do + local connector = v.read and v.read.tag == "table" and v.read:readproperty(types.singleton("Connect")) + if connector then + if not connector then continue end + local params = connector:parameters().head + if not params then continue end + local listener = params[2] + if not listener then continue end + properties:setproperty(i, types.optional(listener)) + elseif v.write then + properties:setproperty(i, types.optional(types.unionof( + v.write, + types.newfunction({}, { head = { v.write } }) + ))) + end + end + + instance = instance:readparent() + end + + properties:setindexer(types.number, types.any) + + return properties end -type Props = { [any]: any } +type AnyProps = { [any]: any } +type Create = ( + (Instance) -> (AnyProps) -> Instance +) & ( + (string, AnyProps) -> Instance +) & ( + (Name|keyof) -> (Properties>) -> index +) -type Create = ((Name, Props) -> Instance) & ((Name) -> (Props) -> Instance) - -return (create :: any) :: - & ( (T & Instance) -> (Props) -> T ) - & ( (T & Instance, Props) -> T ) - & Create<"Folder", Folder> - & Create<"BillboardGui", BillboardGui> - & Create<"CanvasGroup", CanvasGroup> - & Create<"Frame", Frame> - & Create<"ImageButton", ImageButton> - & Create<"ImageLabel", ImageLabel> - & Create<"ScreenGui", ScreenGui> - & Create<"ScrollingFrame", ScrollingFrame> - & Create<"SurfaceGui", SurfaceGui> - & Create<"TextBox", TextBox> - & Create<"TextButton", TextButton> - & Create<"TextLabel", TextLabel> - & Create<"UIAspectRatioConstraint", UIAspectRatioConstraint> - & Create<"UICorner", UICorner> - & Create<"UIGradient", UIGradient> - & Create<"UIGridLayout", UIGridLayout> - & Create<"UIListLayout", UIListLayout> - & Create<"UIPadding", UIPadding> - & Create<"UIPageLayout", UIPageLayout> - & Create<"UIScale", UIScale> - & Create<"UISizeConstraint", UISizeConstraint> - & Create<"UIStroke", UIStroke> - & Create<"UITableLayout", UITableLayout> - & Create<"UITextSizeConstraint", UITextSizeConstraint> - & Create<"VideoFrame", VideoFrame> - & Create<"ViewportFrame", ViewportFrame> - & Create +return (create :: any) :: Create diff --git a/test/create-types.luau b/test/create-types.luau index 3d387e3..3a1e6fd 100644 --- a/test/create-types.luau +++ b/test/create-types.luau @@ -46,7 +46,7 @@ end local vide = require "../src/" local count = vide.source(0) -create("TextButton") { +create("TextButton") { -- todo: why is `:: "TextButton"` not necessary? BackgroundTransparency = 1, AnchorPoint = "bad value", -- should error InvalidProperty = true, -- should error From f370e3f841f0465f5702bd1a49d7e51f3aa0a1b5 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:38:39 +0000 Subject: [PATCH 84/94] Fix type error when not specifying scope destroy delay --- src/indexes.luau | 2 +- src/show.luau | 4 ++-- src/switch.luau | 2 +- src/values.luau | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/indexes.luau b/src/indexes.luau index aa533f4..fe8416e 100644 --- a/src/indexes.luau +++ b/src/indexes.luau @@ -10,7 +10,7 @@ type Source = () -> T local function indexes( input: Source>, - component: (Source, K, Source) -> (Obj, number?) + component: (Source, K, Source) -> (Obj, ...number) ): Source> local update_count = 0 local scopes = {} :: Map = () -> T local function show( input: Source, - component: (Source, Source) -> (Obj, number?), - fallback: ((Source) -> (Obj, number?))? + component: (Source, Source) -> (Obj, ...number), + fallback: ((Source) -> (Obj, ...number))? ): Source> local filtered_input = source() diff --git a/src/switch.luau b/src/switch.luau index ee1af28..0e74932 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -6,7 +6,7 @@ local timeout = require "./timeout" () type Array = { T } type Map = { [K]: V } type Source = () -> T -type Component = (Source) -> (T, number?) +type Component = (Source) -> (T, ...number) local function switch_map( input: Source, diff --git a/src/values.luau b/src/values.luau index c4a3563..33bc5e4 100644 --- a/src/values.luau +++ b/src/values.luau @@ -10,7 +10,7 @@ type Source = () -> T local function values( input: Source>, - component: (V, Source, Source) -> (Obj, number?) + component: (V, Source, Source) -> (Obj, ...number) ): Source> local update_count = 0 local scopes = {} :: Map Date: Thu, 4 Dec 2025 14:22:16 +0000 Subject: [PATCH 85/94] Fix spring impulse control --- src/spring.luau | 102 ++++++++++++++++++++++-------------------- test/spring-test.luau | 97 ++++++++++++++++++++------------------- test/tests.luau | 25 +++++++++++ 3 files changed, 131 insertions(+), 93 deletions(-) diff --git a/src/spring.luau b/src/spring.luau index 89ba34c..ef7decf 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -9,6 +9,7 @@ local update_descendants = graph.update_descendants local push_scope_as_child_of = graph.push_scope_as_child_of local UPDATE_RATE = 120 +local TOLERANCE_FACTOR = 10_000 type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 @@ -24,7 +25,8 @@ type SpringState = { k: number, -- spring constant c: number, -- damping coeff - x0_123: vector, x0_456: vector, -- current position + x0_123: vector, x0_456: vector, -- initial position + x_123: vector, x_456: vector, -- current position x1_123: vector, x1_456: vector, -- target position v_123: vector, v_456: vector, -- current velocity @@ -135,10 +137,8 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local owner = assert_stable_scope() -- https://en.wikipedia.org/wiki/Damping - local w_n = 2*math.pi / (period or 1) local z = damping_ratio or 1 - local k = w_n^2 local c_c = 2*w_n local c = z * c_c @@ -154,10 +154,12 @@ local function spring(source: () -> T, period: number?, damping_ratio: number c = c, x0_123 = vector.zero, + x_123 = vector.zero, x1_123 = vector.zero, v_123 = vector.zero, x0_456 = vector.zero, + x_456 = vector.zero, x1_456 = vector.zero, v_456 = vector.zero, @@ -175,11 +177,10 @@ local function spring(source: () -> T, period: number?, damping_ratio: number end local updater = create_node(owner, updater_effect, false :: any) - evaluate_node(updater) -- set initial position to goal - data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + data.x_123, data.x_456 = data.x1_123, data.x1_456 -- set output to goal output.cache = data.source_value @@ -190,7 +191,9 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local dv = p.impulse if x then - data.x0_123, data.x0_456 = type_to_vec6[typeof(x)](x) + local x_123, x_456 = type_to_vec6[typeof(x)](x) + data.x_123, data.x_456 = x_123, x_456 + data.x0_123, data.x0_456 = x_123, x_456 end if v then @@ -203,6 +206,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number data.v_456 += dv_456 end + -- schedule spring springs[data] = output end :: SpringSettings @@ -214,7 +218,7 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- set current position to value local v = ... :: T - data.x0_123, data.x0_456 = type_to_vec6[typeof(v)](v) + data.x_123, data.x_456 = type_to_vec6[typeof(v)](v) -- reset velocity data.v_123 = vector.zero @@ -230,38 +234,29 @@ local function spring(source: () -> T, period: number?, damping_ratio: number end, config end --- luau vectors have f32 for each component, unlike luau number which is f64 -local FLOAT32_MANTISSA_BITS = 23 -type float32 = number - -local function get_min_step(x: float32) - local _,exponent = math.frexp(x) - - local lower_mantissa = math.ldexp(1, -FLOAT32_MANTISSA_BITS - 1) - return math.ldexp(lower_mantissa, exponent) +-- calculates a float tolerance, based on the magnitude of the float +local function get_min_step(x: number): number + return x/TOLERANCE_FACTOR end -local function get_min_vector_step(goal: vector): vector +local function get_min_vector_step(direction: vector): vector return vector.create( - get_min_step(goal.x), - get_min_step(goal.y), - get_min_step(goal.z) + get_min_step(direction.x), + get_min_step(direction.y), + get_min_step(direction.z) ) end local function step_springs(dt: number) - for data in springs do - local k, c, - x0_123, x1_123, u_123, - x0_456, x1_456, u_456 = - data.k, data.c, - data.x0_123, data.x1_123, data.v_123, - data.x0_456, data.x1_456, data.v_456 - - if x0_123 == x1_123 and x0_456 == x1_456 then continue end + for s in springs do + local k = s.k + local c = s.c + local x_123, x_456 = s.x_123, s.x_456 + local x1_123, x1_456 = s.x1_123, s.x1_456 + local u_123, u_456 = s.v_123, s.v_456 -- calculate displacement from target - local dx_123 = x0_123 - x1_123 - local dx_456 = x0_456 - x1_456 + local dx_123 = x_123 - x1_123 + local dx_456 = x_456 - x1_456 -- calculate spring force local fs_123 = dx_123*-k @@ -271,35 +266,46 @@ local function step_springs(dt: number) local ff_123 = u_123*-c local ff_456 = u_456*-c - -- calculate acceleration step - local dv_123 = (fs_123 + ff_123)*dt - local dv_456 = (fs_456 + ff_456)*dt + -- calculate acceleration + local a_123 = (fs_123 + ff_123) + local a_456 = (fs_456 + ff_456) - -- apply acceleration step - local v_123 = u_123 + dv_123 - local v_456 = u_456 + dv_456 + -- step acceleration + local v_123 = u_123 + a_123*dt + local v_456 = u_456 + a_456*dt - -- guarantee pos < pos + velocity <= goal - local a_123 = vector.max(get_min_vector_step(x0_123), vector.abs(v_123) * dt) * vector.sign(v_123) - local a_456 = vector.max(get_min_vector_step(x0_456), vector.abs(v_456) * dt) * vector.sign(v_456) + -- step velocity + local y_123 = x_123 + v_123*dt + local y_456 = x_456 + v_456*dt - -- calculate new position - local x_123 = x0_123 + a_123 - local x_456 = x0_456 + a_456 - - data.x0_123, data.x0_456 = x_123, x_456 - data.v_123, data.v_456 = v_123, v_456 + s.x_123, s.x_456 = y_123, y_456 + s.v_123, s.v_456 = v_123, v_456 end end local function update_spring_sources() for data, output in springs do local x0_123, x0_456 = data.x0_123, data.x0_456 - if x0_123 == data.x1_123 and x0_456 == data.x1_456 then + local x_123, x_456 = data.x_123, data.x_456 + local x1_123, x1_456 = data.x1_123, data.x1_456 + local v_123, v_456 = data.v_123, data.v_456 + + local tol_123 = vector.abs(get_min_vector_step(x0_123 - x1_123)) + local tol_456 = vector.abs(get_min_vector_step(x0_456 - x1_456)) + + if + -- position is at goal (within tolerance) + vector.max(vector.abs(x_123 - x1_123), tol_123) == tol_123 + and vector.max(vector.abs(x_456 - x1_456), tol_456) == tol_456 + + -- velocity is at 0 (within tolerance) + and vector.max(vector.abs(v_123/10), tol_123) == tol_123 + and vector.max(vector.abs(v_456/10), tol_456) == tol_456 + then springs[data] = nil output.cache = data.source_value else - output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) + output.cache = vec6_to_type[typeof(data.source_value)](x_123, x_456) end update_descendants(output) diff --git a/test/spring-test.luau b/test/spring-test.luau index 17175f5..1385eda 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -1,25 +1,34 @@ local vide = require "../../vide" -local testkit = require("../test/testkit") -local program_time = os.clock() +local function system(): (number) -> number + local MAX = 40 + local MIN = 10 -local function step(): number - local FPS = 60 - local DT = 1/FPS + local _, input, output = vide.root(function() + local input = vide.source(MAX) + local output = vide.spring(input, 1, .3) + return input, output + end) - repeat until os.clock() - program_time >= DT - program_time += DT - return DT + local T = 10 + local t = 0 + return function(dt) + t += dt + if t >= T then + t -= T + input(input() == MAX and MIN or MAX) + end + + vide.step(dt) + + return output() + end end -local function main() - local TERMINAL_HEIGHT = 73 --* REDUCE IF BAR DOES NOT FIT IN TERMINAL - local MIN_ALPHA = 0.3 - local MAX_ALPHA = 0.7 +-------------------------------------------------------------------------------- - local MIN = TERMINAL_HEIGHT * MIN_ALPHA - local MAX = TERMINAL_HEIGHT * MAX_ALPHA - local OFFSET = TERMINAL_HEIGHT - MAX +local function redraw_block(h: number) + local OFFSET = 70 local BLOCK = "█" @@ -35,37 +44,35 @@ local function main() else "▁" end - local source = vide.source - local spring = vide.spring - local effect = vide.effect - - local value = source(MAX) - local sprung = spring(value, 1, 0.3) - - effect(function() - local v = sprung() - local fv = math.floor(v) - local reset = "\27[H\27[2J" -- ANSI clear terminal - local offset = string.rep("\n", MAX - fv + OFFSET) - local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv)) - print(reset .. offset .. bar .. "\n" .. v) - end) - - local T = 3 - local elapsed = T/1.2 - repeat local dt = step() - vide.step(dt) - - elapsed += dt - while elapsed >= T do - elapsed -= T - value(value() == MAX and MIN or MAX) - end - - until false + local h_f = math.floor(h) + local reset = "\27[H\27[2J" -- ANSI clear terminal + local offset = string.rep("\n", OFFSET - h_f) + local bar = remainder_to_block(h - h_f) .. "\n" .. string.rep(BLOCK .. "\n", h_f) + --print(reset .. offset .. bar .. "\n" .. string.format("%.1f", h)) + print(reset .. offset .. bar .. "\n" .. h) end -vide.root(main) - +local program_time = os.clock() + +local function step(): number + local FPS = 30 + local DT = 1/FPS + + repeat until os.clock() - program_time >= DT + program_time += DT + return DT +end + +local function loop() + local callback = system() + + while true do + local dt = step() + local x = callback(dt) + redraw_block(x) + end +end + +loop() diff --git a/test/tests.luau b/test/tests.luau index 9a2c73f..af6a7f4 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -2120,6 +2120,31 @@ TEST("spring()", wrap_root(function() step(0) -- process spring queue CHECK(count == 1) -- check spring was rescheduled correctly end + + do CASE "spring control" + local input = source(1) + local output, control = spring(input) + + local value = input() + local count = 0 + effect(function() + value = output() + count += 1 + end) + + CHECK(count == 1) + CHECK(value == 1) + + control { impulse = 1 } + + CHECK(count == 1) + CHECK(value == 1) + + step(1/120 + 0.001) + + CHECK(count == 2) + CHECK(value > 1) + end end)) TEST("untrack()", wrap_root(function() From cb6022d934b6d100c7d877c0f529a4179027a423 Mon Sep 17 00:00:00 2001 From: Sarim Siddiqui Date: Sat, 3 Jan 2026 23:12:27 +0500 Subject: [PATCH 86/94] add Camera and WorldModel to list of instances recognized by `vide.create` --- src/create.luau | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/create.luau b/src/create.luau index 6273b80..77683eb 100644 --- a/src/create.luau +++ b/src/create.luau @@ -65,6 +65,8 @@ type Instances = { UIGradient: UIGradient, UIGridLayout: UIGridLayout, UIListLayout: UIListLayout, + Camera: Camera, + WorldModel: WorldModel, } type function Properties(instance: type?) From 6da33722e9799c21daca98639d509ba5a56aef7f Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:15:05 +0000 Subject: [PATCH 87/94] Add delayed scope destruction to docs --- CHANGELOG.md | 2 + docs/api/animation.md | 4 +- docs/api/reactivity-dynamic.md | 47 ++++++++++++------ docs/tut/advanced/dynamic-scopes.md | 4 +- docs/tut/crash-course/11-dynamic-scopes.md | 55 +++++++++++++++++++++- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f2dd1..3781126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). effects to set children. - `spring()` returns a second value, a setter to set position, velocity and impulse. +- Improved `spring()` updating and unscheduling. - `show()` now receives a source to its callback returning the current value of the condition. - Ignore `false` passed as a child. - Flag `vide.defaults` to disable the setting of default properties. - Delayed scope destruction for control flow functions: `show()` `switch()` `indexes()` `values()`. +- Better `create()` types for the new type solver. ### Changed diff --git a/docs/api/animation.md b/docs/api/animation.md index 9b5baf4..ca06b39 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -11,11 +11,11 @@ Returns a new source with a value always moving torwards the input source value. source: () -> T & Animatable, period: number = 1, damping_ratio: number = 1 - ): (() -> T, SpringConfig) + ): (() -> T, SpringControl) type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect - type SpringConfig = ({ + type SpringControl = ({ position: T?, velocity: T?, impulse: T? diff --git a/docs/api/reactivity-dynamic.md b/docs/api/reactivity-dynamic.md index 875c12f..f0b3229 100644 --- a/docs/api/reactivity-dynamic.md +++ b/docs/api/reactivity-dynamic.md @@ -1,7 +1,7 @@ -# Reactivity: Dynamic Scoping +# Reactivity: Dynamic Scopes -Dynamic scoping is the act of creating and destroying new scopes in response to -source updates. Vide provides functions for some common use-cases to do this. +Dynamic scopes are scopes that are created or destroyed in response to +source updates. Vide provides functions for some common use-cases for dynamic scopes. ## show() REACTIVE @@ -11,8 +11,10 @@ if the source is falsey. - **Type** ```luau - function show(source: () -> unknown, component: () -> T): () -> T? - function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U + function show(source: () -> unknown, component: Constructor): () -> T? + function show(source: () -> unknown, component: Constructor, fallback: () -> U): () -> T | U + + type Constructor = () -> (T, number?) ``` - **Details** @@ -25,6 +27,9 @@ if the source is falsey. Returns a source holding an instance of the currently shown component or `nil` if no component is currently shown. + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + ## switch() REACTIVE Shows one of a set of components depending on a source and a mapping table. @@ -32,7 +37,9 @@ Shows one of a set of components depending on a source and a mapping table. - **Type** ```luau - function switch(source: () -> K): (map: Map V>): () -> V? + function switch(source: () -> K): (map: Map>): () -> V? + + type Constructor = () -> (T, number?) ``` - **Details** @@ -46,6 +53,9 @@ Shows one of a set of components depending on a source and a mapping table. Returns a source holding an instance of the currently shown component or `nil` if no component is currently shown. + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + - **Example** ```luau @@ -71,8 +81,9 @@ Shows a component for each index in a table. ```luau function indexes( source: () -> Map, - transform: (value: () -> VI, index: KI) -> VO + constructor: (value: () -> VI, index: KI) -> (VO, number?) ): Array + ``` - **Details** @@ -81,21 +92,24 @@ Shows a component for each index in a table. When the source table updates, a component is generated for each index in the table. - - For any added index, the `transform` function is run in a new stable + - For any added index, the `constructor` function is run in a new stable scope to produce an instance that is cached. - For any removed index, the stable scope for that index is destroyed. - The `transform` function is called with: + The `constructor` function is called with: 1. A *source containing the index's value*. 2. The *index itself*. - Anytime an existing index's value changes, the `transform` function is not + Anytime an existing index's value changes, the `constructor` function is not rerun, instead, that index's corresponding source is updated with the new value. Returns a source holding an array of instances currently shown. + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + - **Example** ```luau @@ -128,7 +142,7 @@ Shows a component for each value in a table. ```luau function values( source: () -> Map, - transform: (value: VI, index: () -> KI) -> VO + constructor: (value: VI, index: () -> KI) -> (VO, number?) ): Array - **Details** @@ -141,21 +155,24 @@ Shows a component for each value in a table. When the source table updates, a component is generated for each value in the table. - - For any added value, the `transform` function is run in a new stable scope + - For any added value, the `constructor` function is run in a new stable scope to produce an instance that is cached. - For any removed value, the stable scope for that value is destroyed. - The `transform` function is called with: + The `constructor` function is called with: 1. The *value itself*. 2. A *source containing the value's index*. - Anytime an existing value's index changes, the `transform` function is not + Anytime an existing value's index changes, the `constructor` function is not rerun, instead, that value's corresponding source is updated with the new index. Returns a source holding an array of instances currently shown. - + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + ::: warning Having the same values appear multiple times in the input source table can cause unexpected behavior. Strict mode has checks for this. diff --git a/docs/tut/advanced/dynamic-scopes.md b/docs/tut/advanced/dynamic-scopes.md index 99fe746..139a19c 100644 --- a/docs/tut/advanced/dynamic-scopes.md +++ b/docs/tut/advanced/dynamic-scopes.md @@ -1,6 +1,6 @@ -# Dynamic Scoping +# Dynamic Scopes -Dynamic scoping is the act of creating and destroying new scopes in response to +Dynamic scopes are scopes that are created and destroyed in response to source updates. This is needed for conditionally rendering parts of your UI, such as opening and closing menus. diff --git a/docs/tut/crash-course/11-dynamic-scopes.md b/docs/tut/crash-course/11-dynamic-scopes.md index bede8d8..1c12e51 100644 --- a/docs/tut/crash-course/11-dynamic-scopes.md +++ b/docs/tut/crash-course/11-dynamic-scopes.md @@ -4,7 +4,7 @@ Eventually you may need a way to dynamically create and destroy UI elements resulting from source updates. Vide provides functions to help you do this, known as *dynamic scope* functions. -These functions create and destroy components for you in response to source +These functions create and destroy scopes for you in response to source updates. They return a source containing the created component. This source can be parented as a child which will update the shown children whenever the source updates. @@ -156,3 +156,56 @@ local data = src() table.insert(data, 3) -- no effects will run src(data) -- effects will run ``` + +-------------------------------------------------------------------------------- + +All dynamic scope functions also support delaying the destruction of the scope. +This is useful for playing any sort of animation or effect before the UI +instance is removed. + +If you have the following code, for example: + +```lua +local function Menu() + return create "Frame" {} +end + +local toggled = source(true) + +create "ScreenGui" { + show(toggled, function() + return Menu {} + end) +} + +toggled(false) -- menu will disappear immediately +``` + +```lua +local function Menu(props: { Visible: () -> boolean }) + local transparency = spring(function() + return if p.Visible then 0 else 1 + end + + return create "Frame" { + BackgroundTransparency = transparency + } +end + +local toggled = source(true) + +create "ScreenGui" { + show(toggled, function(_, present) + return Menu { p.Visible = present }, 3 -- give a generous 3 seconds for the spring to complete before destroying + end) +} + +toggled(false) +-- `present` will go `false` immediately +-- transparency will begin being sprung +-- after 3 seconds the scope is destroyed, giving the spring enough time to complete +``` + +If `toggled` goes from truthy to falsey, beginning the timer, but then back +to truthy before the timer finishes, the timer is cancelled and the scope is +not destroyed. From 2184a0fa6ab759b7d6f9e3e4dd29340e3f299de6 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:28:45 +0000 Subject: [PATCH 88/94] Bump version to `0.4.0` --- CHANGELOG.md | 2 +- src/lib.luau | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3781126..8bd1bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## [Unreleased] +## [0.4.0] - 2026-01-17 ### Added diff --git a/src/lib.luau b/src/lib.luau index 3312f26..28784b4 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -1,4 +1,4 @@ -local version = { major = 0, minor = 3, patch = 1 } +local version = { major = 0, minor = 4, patch = 0 } local root = require "./root" local branch = require "./branch" From 820bda8eb127910e958558caf3468d9312b681ae Mon Sep 17 00:00:00 2001 From: wiam <230908809+wiam77@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:15:48 -0300 Subject: [PATCH 89/94] Simplify connector check in Properties type function Removed redundant check for connector in the Properties type function. --- src/create.luau | 1 - 1 file changed, 1 deletion(-) diff --git a/src/create.luau b/src/create.luau index 77683eb..3540555 100644 --- a/src/create.luau +++ b/src/create.luau @@ -76,7 +76,6 @@ type function Properties(instance: type?) for i, v in instance:properties() do local connector = v.read and v.read.tag == "table" and v.read:readproperty(types.singleton("Connect")) if connector then - if not connector then continue end local params = connector:parameters().head if not params then continue end local listener = params[2] From 7deae9c9f61dca9a6d3c69567ed7aac0923db6d1 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:46:13 +0000 Subject: [PATCH 90/94] Allow `vide.branch` to be used in a reactive scope --- CHANGELOG.md | 8 ++++++++ src/branch.luau | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd1bdd..6200484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- +## Unreleased + +### Changed + +- `branch()` is now allowed to be used within a reactive scope. + +-------------------------------------------------------------------------------- + ## [0.4.0] - 2026-01-17 ### Added diff --git a/src/branch.luau b/src/branch.luau index a35c1b0..2138d06 100644 --- a/src/branch.luau +++ b/src/branch.luau @@ -13,8 +13,8 @@ local function branch(fn: () -> T): (() -> (), T) end local parent = current.owner - if not parent or parent.effect then - error(`current scope is not owned by a stable scope`, 0) + if not parent then + error(`current scope is not owned by a scope`, 0) end local node = create_node(parent, false, false) From 19eaeb424fe06168772f8e6d5aaaf3ab5e78a316 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:33:29 +0100 Subject: [PATCH 91/94] Deprecate `create()` overloads Closes #70 --- CHANGELOG.md | 4 ++++ src/create.luau | 48 ++++++++++++++++++-------------------- src/defaults.luau | 2 +- test/create-types.luau | 53 ++++-------------------------------------- 4 files changed, 33 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6200484..4170b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `branch()` is now allowed to be used within a reactive scope. +### Deprecated + +- `create()` overloads. Supported is now only `create(class)(props)`. + -------------------------------------------------------------------------------- ## [0.4.0] - 2026-01-17 diff --git a/src/create.luau b/src/create.luau index 3540555..db33663 100644 --- a/src/create.luau +++ b/src/create.luau @@ -5,11 +5,10 @@ local defaults = require "./defaults" local apply = require "./apply" local flags = require "./flags" -local ctor_cache = {} :: { [string]: (AnyProps) -> Instance } -local function lazy_init(_, class: string) - local function ctor(properties: AnyProps): Instance +local function create_constructor_for_class(class: string): ({ [unknown]: unknown }) -> Instance + local function constructor(properties: { [unknown]: unknown }): Instance local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then error(`invalid class name { class }`, 0) end + if not ok then error(`invalid class name {class}`, 0) end if flags.defaults then local default: { [string]: unknown }? = defaults[class] @@ -23,28 +22,34 @@ local function lazy_init(_, class: string) return apply(instance, properties) end - ctor_cache[class] = ctor - return ctor + return constructor end -setmetatable(ctor_cache, { __index = lazy_init }) + +local constructor_cache = {} :: { [string]: ({ [unknown]: unknown }) -> Instance } -- todo: remove support for different overloads -local function create(class_or_instance: string|Instance, properties: AnyProps?): ((AnyProps) -> Instance) | Instance +local function create(class_or_instance: string|Instance, properties: { [unknown]: unknown }?): unknown if type(class_or_instance) ~= "string" and typeof(class_or_instance) ~= "Instance" then error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0) end - local ctor = if type(class_or_instance) == "string" - then ctor_cache[class_or_instance] - else function(properties) - local clone = class_or_instance:Clone() - if not clone then error "attempt to clone a non-archivable instance" end - return apply(clone, properties) + local constructor: ({ [unknown]: unknown }) -> Instance + if type(class_or_instance) == "string" then + constructor = constructor_cache[class_or_instance] + if not constructor then + constructor = create_constructor_for_class(class_or_instance) + constructor_cache[class_or_instance] = constructor end + else + constructor = function(props) + local clone = assert(class_or_instance:Clone(), "attempt to clone a non-archivable instance") + return apply(clone, props) + end + end return if properties - then ctor(properties) - else ctor + then constructor(properties) + else constructor end type Instances = { @@ -97,13 +102,6 @@ type function Properties(instance: type?) return properties end -type AnyProps = { [any]: any } -type Create = ( - (Instance) -> (AnyProps) -> Instance -) & ( - (string, AnyProps) -> Instance -) & ( - (Name|keyof) -> (Properties>) -> index -) +type Create = (Name|keyof|"") -> (Properties>) -> index -return (create :: any) :: Create +return create :: Create diff --git a/src/defaults.luau b/src/defaults.luau index bcdabaf..77040ff 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -110,4 +110,4 @@ return { BorderColor3 = Color3.new(0, 0, 0), BorderSizePixel = 0 } -} +} :: { [string]: { [string]: unknown} } diff --git a/test/create-types.luau b/test/create-types.luau index 3a1e6fd..b30ffd5 100644 --- a/test/create-types.luau +++ b/test/create-types.luau @@ -1,52 +1,8 @@ -type Pseudo = { - Test: number -} - -type Instances = { - TextLabel: TextLabel, - TextButton: TextButton, - ImageLabel: ImageLabel, - Pseudo: Pseudo -} - -type function Properties(instance: type?) - local properties = types.newtable() - - while instance do - for i, v in instance:properties() do - local connector = v.read and v.read.tag == "table" and v.read:readproperty(types.singleton("Connect")) - if connector then - if not connector then continue end - local params = connector:parameters().head - if not params then continue end - local listener = params[2] - if not listener then continue end - properties:setproperty(i, types.optional(listener)) - elseif v.write then - properties:setproperty(i, types.optional(types.unionof( - v.write, - types.newfunction({}, { head = { v.write } }) - ))) - end - end - - instance = instance:readparent() - end - - properties:setindexer(types.number, types.any) - - return properties -end - -local function create(name: Name | keyof): (Properties>) -> index - return function() - return "" :: any - end -end - local vide = require "../src/" + local count = vide.source(0) -create("TextButton") { -- todo: why is `:: "TextButton"` not necessary? + +vide.create("TextButton") { BackgroundTransparency = 1, AnchorPoint = "bad value", -- should error InvalidProperty = true, -- should error @@ -65,6 +21,7 @@ create("TextButton") { -- todo: why is `:: "TextButton"` not necessary? Activated = "bad value", -- should error - create "TextLabel" {}, + vide.create "TextLabel" {}, + function() end, } From 5ed4c01940e6bd578fb83253cfbeda0a6c05177c Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:25:38 +0100 Subject: [PATCH 92/94] Bump version to `0.4.1` --- CHANGELOG.md | 6 +++++- src/lib.luau | 2 +- test/{create-types.luau => create-type-test.luau} | 0 3 files changed, 6 insertions(+), 2 deletions(-) rename test/{create-types.luau => create-type-test.luau} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4170b6a..3e584d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- -## Unreleased +## [0.4.1] - 2026-07-11 ### Changed @@ -16,6 +16,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `create()` overloads. Supported is now only `create(class)(props)`. +### Fixed + +- `create()` types in the new solver should now work without `::`. + -------------------------------------------------------------------------------- ## [0.4.0] - 2026-01-17 diff --git a/src/lib.luau b/src/lib.luau index 28784b4..671f1c6 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -1,4 +1,4 @@ -local version = { major = 0, minor = 4, patch = 0 } +local version = { major = 0, minor = 4, patch = 1 } local root = require "./root" local branch = require "./branch" diff --git a/test/create-types.luau b/test/create-type-test.luau similarity index 100% rename from test/create-types.luau rename to test/create-type-test.luau From 452060a533a8f5609ae4057d28cdfbfcdeddc2e4 Mon Sep 17 00:00:00 2001 From: centauri <83140718+centau@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:31:48 +0100 Subject: [PATCH 93/94] Bump wally and pesde version --- .github/workflows/wallypesde.yml | 1 + pesde.toml | 2 +- wally.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wallypesde.yml b/.github/workflows/wallypesde.yml index 29198ab..0c705b0 100644 --- a/.github/workflows/wallypesde.yml +++ b/.github/workflows/wallypesde.yml @@ -1,6 +1,7 @@ name: publish to wally and pesde on: + workflow_dispatch: release: types: [published] diff --git a/pesde.toml b/pesde.toml index aec01c5..94c8ede 100644 --- a/pesde.toml +++ b/pesde.toml @@ -1,5 +1,5 @@ name = "centau/vide" -version = "0.4.0" +version = "0.4.1" description = "A reactive Luau library for creating UI." authors = ["centau"] repository = "https://github.com/centau/vide" diff --git a/wally.toml b/wally.toml index 2b44317..1584e06 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.4.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"] From f3bfc65607834370ce84a6e16722282c4d30316c Mon Sep 17 00:00:00 2001 From: raine <155107924+raineyraine@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:43:28 -0400 Subject: [PATCH 94/94] feat: export vide.create types and include more ui element types (#79) * feat: add more ui instances to Create type * fix: use string requires in init.luau @self string requires are now supported by Roblox, so this should be fine. It works in my Pesde patches and fixes issues with missing types. * feat: export vide.create types Instances and Properties are both useful types that should be able to be used by consumers (e.g. wrapper components). * fix: typo * feat: include exports in repo init.luau Fixes #84 --- init.luau | 2 ++ src/create.luau | 16 ++++++++++++++-- src/init.luau | 4 +++- src/lib.luau | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/init.luau b/init.luau index fcd8183..a9ae8e0 100644 --- a/init.luau +++ b/init.luau @@ -4,5 +4,7 @@ export type source = vide.source export type Source = vide.Source export type context = vide.context export type Context = vide.Context +export type Instances = vide.Instances +export type Properties = vide.Properties return vide diff --git a/src/create.luau b/src/create.luau index db33663..120d17d 100644 --- a/src/create.luau +++ b/src/create.luau @@ -52,7 +52,7 @@ local function create(class_or_instance: string|Instance, properties: { [unknown else constructor end -type Instances = { +export type Instances = { Folder: Folder, BillboardGui: BillboardGui, CanvasGroup: CanvasGroup, @@ -70,11 +70,23 @@ type Instances = { UIGradient: UIGradient, UIGridLayout: UIGridLayout, UIListLayout: UIListLayout, + UISizeConstraint: UISizeConstraint, + UITextSizeConstraint: UITextSizeConstraint, + UIScale: UIScale, + UIPadding: UIPadding, + UIStroke: UIStroke, + UIFlexItem: UIFlexItem, + UIPageLayout: UIPageLayout, + UITableLayout: UITableLayout, + VideoFrame: VideoFrame, + ViewportFrame: ViewportFrame, + ProximityPrompt: ProximityPrompt, + UIDragDetector: UIDragDetector, Camera: Camera, WorldModel: WorldModel, } -type function Properties(instance: type?) +export type function Properties(instance: type?) local properties = types.newtable() while instance do diff --git a/src/init.luau b/src/init.luau index 11d1bd2..c0fbe0e 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,10 +1,12 @@ assert(game, "when using vide outside of Roblox, require lib.luau instead") -local vide = require(script.lib) +local vide = require("@self/lib") export type source = vide.source export type Source = vide.Source export type context = vide.context export type Context = vide.Context +export type Instances = vide.Instances +export type Properties = vide.Properties return vide diff --git a/src/lib.luau b/src/lib.luau index 671f1c6..a8a3885 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -27,6 +27,8 @@ export type Source = source.Source export type source = Source export type Context = context.Context export type context = Context +export type Instances = create.Instances +export type Properties = create.Properties local function step(dt: number) if game then debug.profilebegin("VIDE STEP") end