This commit is contained in:
Aaron Smith 2023-08-02 16:12:05 +01:00
parent f7ce5f024d
commit 7cdcebf03c
11 changed files with 367 additions and 233 deletions

View file

@ -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<T> = graph.Node<T>

46
src/cleanup.lua Normal file
View file

@ -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

View file

@ -9,18 +9,8 @@ local create = graph.create
local get = graph.get
local capture_and_link = graph.capture_and_link
local function derive<T>(fn: () -> T, cleanup: (T) -> ()?): () -> T
local function derive<T>(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)

View file

@ -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
}

View file

@ -93,14 +93,14 @@ local lerpable: { [string]: Lerp<any> } = {
local springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" })
local function spring<T>(target: () -> T, period: number, damping_ratio: number?): () -> T
local function spring<T>(target: () -> T, period: number?, damping_ratio: number?): () -> T
local initial_position = target()
local node = create(initial_position)
local data: SpringData<T> = {
alpha = 0,
duration = 0,
period = period,
period = period or 1,
damping_ratio = damping_ratio or 1,
velocity = 0,
initial_velocity = 0,

View file

@ -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

View file

@ -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

View file

@ -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<T>(template: T & {}): T
local function deep_clone<T>(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 {

View file

@ -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<T> = {
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<any>
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

View file

@ -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)

26
todo.md
View file

@ -39,6 +39,32 @@ async/loading/suspense
define order with nested properties
```lua
type Action<T> = {
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