diff --git a/src/apply.lua b/src/apply.lua index e5118e0..75d38c6 100644 --- a/src/apply.lua +++ b/src/apply.lua @@ -2,7 +2,10 @@ -- vide/apply.lua ------------------------------------------------------------------------------------------ -if not game then script = (require :: any) "test/wrap-require" end +if not game then + script = (require :: any) "test/wrap-require" + typeof = require "test/mock".typeof +end local graph = require(script.Parent.graph) type Node = graph.Node diff --git a/src/cleanup.lua b/src/cleanup.lua new file mode 100644 index 0000000..efe96e2 --- /dev/null +++ b/src/cleanup.lua @@ -0,0 +1,46 @@ +------------------------------------------------------------------------------------------ +-- vide/cleanup.lua +------------------------------------------------------------------------------------------ + +if not game then script = require "test/wrap-require" end + +-- todo: verify correct behavior in non-standard usage +local cleanup_callbacks = {} :: { [string]: () -> () } +local cleanup_callers = {} :: { [string]: () -> () } + +setmetatable(cleanup_callers :: any, { __mode = "vs" }) + +-- todo: rare case where mem address is reused by another function on same line + +local function cleanup(callback: () -> ()) + local caller = debug.info(2, "f") :: () -> () + local line = debug.info(2, "l") :: number + local ref = tostring(caller) .. "\0" .. line + + local fn = cleanup_callbacks[ref] + if fn then + fn() + else + cleanup_callers[ref] = caller + end + cleanup_callbacks[ref] = callback +end + +local buffer = {} + +local function clean_garbage() + for ref, callback in next, cleanup_callbacks do + if cleanup_callers[ref] == nil then -- caller was garbage collected + callback() + table.insert(buffer, ref) + end + end + + for _, ref in next, buffer do + cleanup_callbacks[ref] = nil + end + + table.clear(buffer) +end + +return function() return cleanup, clean_garbage end diff --git a/src/derive.lua b/src/derive.lua index ead6911..938f8b2 100644 --- a/src/derive.lua +++ b/src/derive.lua @@ -9,18 +9,8 @@ local create = graph.create local get = graph.get local capture_and_link = graph.capture_and_link -local function derive(fn: () -> T, cleanup: (T) -> ()?): () -> T +local function derive(fn: () -> T): () -> T local node = create((nil :: any) :: T) - - if cleanup then - local f = fn - local last: T? = nil - fn = function() - if last ~= nil then cleanup(last) end - last = f() - return last :: T - end - end node.cache = capture_and_link(node, fn) diff --git a/src/init.lua b/src/init.lua index 18d9958..7ac63c8 100644 --- a/src/init.lua +++ b/src/init.lua @@ -8,6 +8,7 @@ if not game then script = (require :: any) "test/wrap-require" end local create = require(script.create) local source = require(script.source) local watch = require(script.watch) +local cleanup, clean_garbage = require(script.cleanup)() local derive = require(script.derive) local map = require(script.map) @@ -25,9 +26,10 @@ local vide = { -- core create = create, source = source, + watch = watch, + cleanup = cleanup, derive = derive, map = map, - watch = watch, -- animations spring = spring, @@ -45,6 +47,7 @@ local vide = { -- test step = function(dt: number) update_springs(dt) + clean_garbage() end } diff --git a/src/spring.lua b/src/spring.lua index 8495897..16602d0 100644 --- a/src/spring.lua +++ b/src/spring.lua @@ -93,14 +93,14 @@ local lerpable: { [string]: Lerp } = { local springs: { [SpringData]: Node } = {} setmetatable(springs, { __mode = "vs" }) -local function spring(target: () -> T, period: number, damping_ratio: number?): () -> T +local function spring(target: () -> T, period: number?, damping_ratio: number?): () -> T local initial_position = target() local node = create(initial_position) local data: SpringData = { alpha = 0, duration = 0, - period = period, + period = period or 1, damping_ratio = damping_ratio or 1, velocity = 0, initial_velocity = 0, diff --git a/src/watch.lua b/src/watch.lua index 6378412..0e5fd18 100644 --- a/src/watch.lua +++ b/src/watch.lua @@ -9,25 +9,18 @@ local set_effect = graph.set_effect local capture = graph.capture local function watch(effect: () -> ()): () -> () - -- todo: call cleanup on initial debug call when strict - local nodes, cleanup = capture(effect :: () -> (() -> ()?), nil) + local nodes = capture(effect :: () -> nil) nodes = table.clone(nodes) - local function fn() - if cleanup then cleanup(); cleanup = nil end - cleanup = effect() - end - for _, node in next, nodes do - set_effect(node, fn, true) + set_effect(node, effect, true) end local function unwatch() for _, node in next, nodes do - set_effect(node, fn, nil) + set_effect(node, effect, nil) end - if cleanup then cleanup(); cleanup = nil end end return unwatch diff --git a/test/goodsignal.lua b/test/goodsignal.lua index 26aae6d..e96ceaa 100644 --- a/test/goodsignal.lua +++ b/test/goodsignal.lua @@ -1,9 +1,12 @@ +--!nocheck -- wrapped task library for use in pure luau local task = { spawn = function(thread, ...) local ok, err = coroutine.resume(thread, ...) if not ok then error(err, 3) end end } +export type Type = RBXScriptSignal & { Fire: (Type, ...any)-> () } + ---------------------------------------------------------------------------------------------------- -- Batched Yield-Safe Signal Implementation -- -- This is a Signal class which has effectively identical behavior to a -- @@ -106,10 +109,12 @@ setmetatable(Connection, { local Signal = {} Signal.__index = Signal -function Signal.new() +Signal.__type = "RBXScriptSignal" + +function Signal.new(): Type return setmetatable({ _handlerListHead = false, - }, Signal) + }, Signal) :: any end function Signal:Connect(fn) @@ -174,14 +179,4 @@ function Signal:Once(fn) return cn end --- Make signal strict -setmetatable(Signal, { - __index = function(tb, key) - error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) - end, - __newindex = function(tb, key, value) - error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) - end -}) - return Signal diff --git a/test/mock.luau b/test/mock.luau index 0139bdc..e943a25 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -1,101 +1,111 @@ local Instance = {} do local Signal = require "test/goodsignal" + type Signal = Signal.Type - type userdata = { __USERDATA__: true } + type userdata = { __USERDATA: true } - type Proxy = { - _Userdata: userdata, - _Data: Data, + --[[ + + attempt to mimic roblox engine's method of userdata proxy to actual instance data + proxy can gc independantly of actual instance data + proxy prevents gc of actual instance data + luau code never has direct access to actual instance data, only to proxy + + proxy knows data + data does not know proxy + separate weak map kept data -> proxy + + ]] + + type ProxyMT = { + proxy: userdata, + data: Data, __index: any, __newindex: any } type Data = { - Name: string, - Parent: Data?, - Children: { Data }, - Changed: { [string]: RBXScriptSignal & { Fire: any } }, - Table: { [string]: unknown }, - Destroying: RBXScriptSignal, - ClassName: string, - Type: "Instance" + name: string, + parent: Data?, + children: { Data }, + changed: { [string]: Signal }, + properties: { [string]: unknown }, + destroying: Signal, + class: string, + type: "Instance" } - local function deepclone(template: T & {}): T + local function deep_clone(template: T & {}): T local t = table.clone(template :: {}) :: {} for i, v in next, t do if type(v) == "table" then - t[i] = deepclone(v) + t[i] = deep_clone(v) end end return t :: T & {} end - --[[ - attempt to mimic roblox engine's method of userdata proxy to actual instance data - proxy can gc independantly of actual instance data - proxy prevents gc of actual instance data - luau code never has direct access to actual instance data, only to proxy - ]] + + local proxies = {} :: { [Data]: userdata? } setmetatable(proxies :: any, { __mode = "v" }) - local function getdata(userdata: userdata): Data - local function getproxy(userdata: userdata): Proxy + local function get_data(userdata: userdata): Data + local function f(userdata: userdata): ProxyMT return getmetatable(userdata :: any) end - return getproxy(userdata)._Data + return f(userdata).data end - local function isInstance(value: unknown): boolean + local function is_instance(value: unknown): boolean local mt = getmetatable(value :: any) - return mt and mt._Data and mt._Data.Type == "Instance" + return mt and mt.data and mt.data.type == "Instance" end local methods = {} local function __index(userdata: userdata, property: string): () - local data = getdata(userdata) + 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 == "Destroying" then data.Destroying - else data.Table[property] + elseif property == "Name" then data.name + elseif property == "Parent" then data.parent + elseif property == "Destroying" then data.destroying + else data.properties[property] end local function __newindex(userdata: userdata, property: string, value: unknown) - local data = getdata(userdata) + local data = get_data(userdata) if property == "Name" then - data.Name = value :: string + data.name = value :: string elseif property == "Parent" then - assert(value == nil or isInstance(value), "attempt to set non-instance as parent") - local parent = data.Parent + assert(value == nil or is_instance(value), "attempt to set non-instance as parent") + local parent = data.parent if parent then - data.Parent = nil - table.remove(parent.Children, table.find(parent.Children, data)) + data.parent = nil + table.remove(parent.children, table.find(parent.children, data)) end if value then - data.Parent = getdata(value :: userdata) - table.insert(getdata(value :: userdata).Children, data) + data.parent = get_data(value :: userdata) + table.insert(get_data(value :: userdata).children, data) end else - data.Table[property] = value + data.properties[property] = value end - if data.Changed[property] then - data.Changed[property]:Fire() + if data.changed[property] then + data.changed[property]:Fire() end end - local function getuserdata(data: Data): userdata + local function get_proxy(data: Data): userdata return proxies[data] or (function() local userdata = newproxy(true) local proxy = getmetatable(userdata) - proxy._Userdata = userdata - proxy._Data = data + proxy.proxy = userdata + proxy.data = data proxy.__index = __index proxy.__newindex = __newindex proxies[data] = userdata @@ -105,29 +115,29 @@ local Instance = {} do function Instance.new(class: string): Instance local data = { - Name = "UNNAMED", - Parent = nil, - Children = {}, - Changed = {}, - Table = {}, - ClassName = class, - Destroying = Signal.new() :: any, - Type = "Instance" :: "Instance" + name = "UNNAMED", + parent = nil, + children = {}, + changed = {}, + properties = {}, + class = class, + destroying = Signal.new() :: any, + type = "Instance" :: "Instance" } - return getuserdata(data) :: any + return get_proxy(data) :: any end - function Instance.isInstance(value: unknown): boolean - return isInstance(value) + function Instance.is_instance(value: unknown): boolean + return is_instance(value) end function methods.Clone(userdata: userdata): userdata - local data = getdata(userdata) + local data = get_data(userdata) local clone_userdata = (Instance.new("") :: any) :: userdata - local clone_data = getdata(clone_userdata) + local clone_data = get_data(clone_userdata) - for i, v in next, deepclone(data) do + for i, v in next, deep_clone(data) do clone_data[i] = v end @@ -135,40 +145,40 @@ local Instance = {} do end function methods.FindFirstChild(userdata: userdata, target: string): userdata? - local data = getdata(userdata) - for _, child in data.Children do - if child.Name == target then - return getuserdata(child) + local data = get_data(userdata) + for _, child in data.children do + if child.name == target then + return get_proxy(child) end end return nil end function methods.GetChildren(userdata: userdata): { userdata } - local children = getdata(userdata).Children + local children = get_data(userdata).children local userdatas = table.create(#children) for i, child in next, children do - userdatas[i] = getuserdata(child) + userdatas[i] = get_proxy(child) end return userdatas end function methods.GetPropertyChangedSignal(userdata: userdata, property: string): RBXScriptSignal - local data = getdata(userdata) - if not data.Changed[property] then - data.Changed[property] = Signal.new() :: any + local data = get_data(userdata) + if not data.changed[property] then + data.changed[property] = Signal.new() :: any end - return data.Changed[property] + return data.changed[property] end function methods.Destroy(userdata: userdata) - local data = getdata(userdata); - (data.Destroying :: any):Fire() - data.Parent = nil - if data.Changed["Parent"] then - data.Changed["Parent"]:Fire() + local data = get_data(userdata); + data.destroying:Fire() + data.parent = nil + if data.changed["Parent"] then + data.changed["Parent"]:Fire() end end end @@ -227,8 +237,10 @@ local Enum = {} :: any do end}) end -local function typeof(v) - return Instance.isInstance(v) and "Instance" or type(v) +local function typeof(v): string + return if Instance.is_instance(v) then "Instance" + elseif getmetatable(v) and getmetatable(v).__type then getmetatable(v).__type + else type(v) end return { diff --git a/test/syntax.luau b/test/syntax.luau index fd1168a..19a2abc 100644 --- a/test/syntax.luau +++ b/test/syntax.luau @@ -1,72 +1,43 @@ local vide = require "src/init" -local wrap = vide.wrap +local source = vide.source local derive = vide.derive local map = vide.map local create = vide.create -local unwrap = vide.unwrap -local Event = vide.Event -local Layout = vide.Layout - -do - local count = wrap(0) - local count2 = derive(function(from) - local c = from(count) * unwrap(count) - return c ^ 2 - end) - local x = count2.value - - local count2b = count + 1 - local xb = unwrap(count2b) -end - -local count = wrap(0) -local v = count.value - -do - local t = map(1, function(i) - return "" - end) - - local t = map({ true }, function(i, v) - return 1 - end) - - local data = wrap { "" } - - local t = map(data, function(i, v) - return 1 - end) -end - - - -create("Frame") { +type Action = { + type: T, + priority: number, + callback: (Instance) -> () } -local Value, Computed, peek, OnEvent = nil :: any, nil :: any, nil :: any, nil :: any -local New = nil :: any +local function processor(priority: number, fn: (Instance) -> ()): Action -local function Counter(props) - local count, set = wrap(0) - - return create("TextLabel") { - Text = "Count: " .. count, - - [Layout] = props[Layout], - - [Event.Activated] = function() - set(count + 1) - end - } end -local function Frame(props) - return create("Frame") { - Position = props.Position - } +local function Changed(property: string, callback: () -> ()) + return processor(1, function(instance) + instance:GetPropertyChangedSignal(property):Connect(callback) + end) :: Action<"Changed"> end -Frame { Position = UDim2.fromScale(0.5, 0.5) } +local function Cleanup(t: {}) -Frame { Position = positionState } +end + +function TextInput(p: { + OnInput: Action<"Changed"> +}) + create "TextBox" { + Text = "test", + + Changed("Text", function() + + end), + + Cleanup { + + }, + + create("Frame") {} + } +end diff --git a/test/tests.luau b/test/tests.luau index adb79e1..c68bee6 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -363,6 +363,7 @@ end) TEST("watch()", function() local source = vide.source local watch = vide.watch + local cleanup = vide.cleanup do CASE "Capture states" local a = source(1) @@ -405,7 +406,7 @@ TEST("watch()", function() local unwatch = watch(function() state() effect_runcount += 1 - return function() cleanup_runcount += 1 end + cleanup(function() cleanup_runcount += 1 end) end) CHECK(effect_runcount == 1) @@ -413,7 +414,12 @@ TEST("watch()", function() state(2) CHECK(effect_runcount == 2) CHECK(cleanup_runcount == 1) + unwatch() + unwatch = nil :: any + gc() + vide.step(0) + CHECK(effect_runcount == 2) CHECK(cleanup_runcount == 2) end @@ -478,6 +484,117 @@ TEST("watch()", function() end end) +TEST("cleanup()", function() + local source = vide.source + local derive = vide.derive + local watch = vide.watch + local cleanup = vide.cleanup + + do CASE "Cleanup runs for watcher" + local state = source(1) + + local watched = 0 + local cleaned = 0 + + local stop = watch(function() + state() + watched += 1 + cleanup(function() + cleaned += 1 + end) + end) + + CHECK(watched == 1) + CHECK(cleaned == 0) + + state(2) + + CHECK(watched == 2) + CHECK(cleaned == 1) + + stop() + + do -- vide detects by iterating through and checking for gc'd refs + stop = nil :: any + gc() + vide.step(0) + end + + CHECK(watched == 2) + CHECK(cleaned == 2) + end + + do CASE "Scoped" + local function setup() + local state = source(1) + local obj = { cleaned = 0 } + + local _stop = watch(function() + state() + cleanup(function() + obj.cleaned += 1 + end) + end) + + return state, obj + end + + local stateA, objA = setup() + local stateB, objB = setup() + + CHECK(objA.cleaned == 0) + CHECK(objB.cleaned == 0) + + stateA(2) + + CHECK(objA.cleaned == 1) + CHECK(objB.cleaned == 0) + + stateB(2) + + CHECK(objA.cleaned == 1) + CHECK(objB.cleaned == 1) + + do + stateA = nil :: any + stateB = nil :: any + gc() + vide.step(0) + end + + CHECK(objA.cleaned == 2) + CHECK(objB.cleaned == 2) + end + + do CASE "Multiple cleanup" + local state = source(1) + + local queue = {} + + watch(function() + state() + cleanup(function() table.insert(queue, 1) end) + cleanup(function() table.insert(queue, 2) end) + end) + + CHECK(testkit.seq(queue, {})) + state(2) + CHECK(testkit.seq(queue, { 1, 2 })) + state(3) + CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) + + do + state = nil :: any + gc() + vide.step(0) + end + + -- todo: guarantee call order when gc? (currently not) + --testkit.print2(queue) + --CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1, 2 })) + end +end) + TEST("create()", function() local create = vide.create local source = vide.source @@ -894,6 +1011,7 @@ TEST("map()", function() end) TEST("spring()", function() + local create = vide.create local source = vide.source local spring = vide.spring @@ -909,43 +1027,43 @@ TEST("spring()", function() end do CASE "Garbage collection" - do -- `spring` should not allow gc of `state` - local state = source(10) - local _springed = spring(state, 1, 1) + do -- `output` should not allow gc of `input` + local input = source(10) + local output = spring(input) - wref.state, state = state, nil :: any + local wref = { input } + input = nil :: any gc() - CHECK(wref.state) - + CHECK(wref[1]) end - do -- `value` should allow gc of `spring` - local value = source(10) - local springed = spring(value, 1, 1) :: State? + do -- `input` should allow gc of `output` + local input = source(10) + local output = spring(input) - wref.springed, springed = springed, nil + local wref = { output } + output = nil :: any gc() - CHECK(not wref.springed) - + CHECK(not wref[1]) end end - local create = vide.create do CASE "Garbage collection (binded)" - local number = source(10) - local springed = spring(number, 1, 1) :: State? + local input = source(10) + local output = spring(input, 1, 1) local _label = create "TextLabel" { - Text = springed + Text = output } - wref.springed, springed = springed, nil + local wref = { output } + output = nil :: any gc() - CHECK(wref.springed) -- `springed` should not gc + CHECK(wref[1]) -- `output` should not gc end end) @@ -954,9 +1072,11 @@ TEST("Events", function() local function Thing(props) local instance = Instance.new("Thing") :: any - instance.Signal = (Signal.new() :: any) :: RBXScriptSignal & { Fire: any } - testkit.print2(getmetatable(instance)) - return create(instance)(props) + instance.Signal = Signal.new() + + local clone = create(instance)(props) + + return clone end do CASE "Connect event" @@ -969,7 +1089,7 @@ TEST("Events", function() end } - testkit.print2(getmetatable(val)) + -- testkit.print2(getmetatable(val)) CHECK(not connected) val.Value = 1; val.Signal:Fire(val.Value) @@ -977,6 +1097,7 @@ TEST("Events", function() end end) +--[[ TEST("Changed", function() local create = vide.create local Changed = vide.Changed @@ -1023,6 +1144,7 @@ TEST("Changed", function() CHECK(Changed.Test == Changed.Test) end end) +]] --[[ TEST("Created", function() @@ -1050,7 +1172,6 @@ TEST("strict", function() vide.strict = true local source = vide.source - local unsource = vide.unsource local derive = vide.derive local watch = vide.watch @@ -1058,9 +1179,9 @@ TEST("strict", function() local state = source(1) local ok = pcall(function() - local _derived = derive(function(from) + local _derived = derive(function() coroutine.yield() - return from(state) + return state() end) end) @@ -1071,9 +1192,9 @@ TEST("strict", function() local state = source(1) local ok = pcall(function() - local _derived = watch(function(from) + local _derived = watch(function() coroutine.yield() - local _ = from(state) + state() end) end) @@ -1081,59 +1202,33 @@ TEST("strict", function() end do CASE "Run derived callback twice" - local state, set = source(1) + local state = source(1) local runcount = 0 - local derived = derive(function(from) + local _ = derive(function() runcount += 1 - return from(state) + return state() end) CHECK(runcount == 2) - set(2) - local _ = derived() + state(2) CHECK(runcount == 4) end do CASE "Run watcher callback twice" - local state, set = source(1) + local state = source(1) local runcount = 0 - watch(function(from) + watch(function() runcount += 1 - local _ = from(state) + state() end) CHECK(runcount == 2) - set(2) - vide.step(1/60) + state(2) CHECK(runcount == 4) end - do CASE "Does not allow non-layout properties" - local ok = pcall(function() - vide.create "Frame" { - [vide.Layout] = { - AnchorPoint = Vector2.new(0, 0.5), - BackgroundColor3 = Color3.new(0, 0, 0) - } - } - end) - CHECK(not ok) - end - - do CASE "Does not allow same table set" - local _, set = source() - - local t = {} - - set(t) - - local ok = pcall(set, t) - - CHECK(not ok) - end - -- todo: add case for strict mode bindings end) diff --git a/todo.md b/todo.md index b00be91..d1e704f 100644 --- a/todo.md +++ b/todo.md @@ -39,6 +39,32 @@ async/loading/suspense define order with nested properties +```lua +type Action = { + type: T, + priority: number, + callback: (Instance) -> () +} + +local function action(priority: number, fn: (Instance) -> ()): Action + +end + +local function Changed(property: string, callback: () -> ()) + return action(1, function(instance) + instance:GetPropertyChangedSignal(property):Connect(callback) + end) :: Action<"Changed"> +end + +create "TextBox" { + Text = "test", + + Changed "Text" < function(self, data) + + end +} +``` + ## version 1 ```lua