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)