diff --git a/CHANGELOG.md b/CHANGELOG.md index ef49bb2..2cde25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ 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. +- Ignore `false` passed as a child. ### Changed @@ -25,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/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index a9a61d9..57cec00 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. diff --git a/src/apply.luau b/src/apply.luau index 95645da..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 @@ -93,7 +100,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 @@ -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/src/show.luau b/src/show.luau index b3c3fef..c4d87db 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,16 +1,33 @@ -local switch = require "./switch" +local source = require "./source" +local derive = require "./derive" +local effect = require "./effect" +local untrack = require "./untrack" -local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? - local function truthy() - return not not source() - end +local function show(input: () -> T?, component: (() -> T) -> U, fallback: (() -> U)?): () -> U? + local filtered_input = source() - return switch(truthy) { - [true] = component, - [false] = fallback, - } + effect(function() + local v = input() + if v then + filtered_input(v) + end + end) + + 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 input_is_truthy() then untrack(function() return component(filtered_input :: () -> 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 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/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 e7d10c6..5402a75 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" { { @@ -819,6 +826,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") @@ -1073,20 +1094,147 @@ 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 "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) @@ -1109,7 +1257,6 @@ TEST("show()", wrap_root(function() destroyed += 1 end) end) - return nil end) end) end) @@ -2722,7 +2869,6 @@ TEST("strict", wrap_root(function() vide.cleanup(function() count_2 += 1 end) return {} end) - return nil end) end) end) @@ -2749,7 +2895,6 @@ TEST("strict", wrap_root(function() vide.cleanup(function() count_2 += 1 end) return {} end) - return nil end) end) end)