Limit cleanup() calls to once per scope

This commit is contained in:
Aaron Smith 2023-08-22 11:46:56 +01:00
parent 2aebaf5abb
commit 0105e33fac
5 changed files with 63 additions and 5 deletions

View file

@ -2,7 +2,7 @@
## cleanup() ## cleanup()
Runs a callback anytime a reactive scope is re-ran. Runs a callback anytime a function scope is re-ran.
- **Type** - **Type**
@ -10,6 +10,19 @@ Runs a callback anytime a reactive scope is re-ran.
function cleanup(callback: () -> ()) function cleanup(callback: () -> ())
``` ```
- **Details**
The primary purpose of this function is to provide a means of cleaning up
side effects caused by source updates and `watch()` updates.
The stack is inspected to find the function that calls `cleanup()`. The
callback passed is called anytime the caller is re-ran, and when the caller
finally garbage collects.
::: warning
Only one `cleanup()` call is allowed per function scope.
:::
- **Example** - **Example**
```lua ```lua

View file

@ -17,6 +17,7 @@ Currently, strict mode will:
4. Checks for `indexes()` and `values()` returning primitive values. 4. Checks for `indexes()` and `values()` returning primitive values.
5. Checks for duplicate nested properties at same depth. 5. Checks for duplicate nested properties at same depth.
6. Better error reporting and stack traces. 6. Better error reporting and stack traces.
7. Checks for multiple `cleanup()` calls in the same function scope.
By rerunning sources and watchers, any side-effects are made more apparent. By rerunning sources and watchers, any side-effects are made more apparent.
This also helps ensure that cleanups are being handled correctly. This also helps ensure that cleanups are being handled correctly.

View file

@ -1,17 +1,23 @@
if not game then script = require "test/relative-string" end if not game then script = require "test/relative-string" end
local flags = require(script.Parent.flags)
local throw = require(script.Parent.throw)
local ref_to_id = {} :: { [string]: number } local ref_to_id = {} :: { [string]: number }
local id_to_ref = {} :: { [number]: string } local id_to_ref = {} :: { [number]: string }
local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense local cleanup_callbacks = {} :: { [number]: () -> () } -- always dense
local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse local cleanup_lifetime = {} :: { [number]: unknown } -- can be sparse
setmetatable(cleanup_lifetime :: any, { __mode = "v" }) setmetatable(cleanup_lifetime :: any, { __mode = "v" })
local debug_caller_to_line = {} :: { [() -> ()]: number }
setmetatable(debug_caller_to_line, { __mode = "k" })
local manual_mode = { local manual_mode = {
caller = false :: false | () -> (), caller = false :: false | () -> (),
callbacks = {} :: { () -> () } callbacks = {} :: { () -> () }
} }
-- todo: rare case where mem address is reused by another function on same line -- todo: rare case where mem address is reused by another function
-- does this case handle itself? -- does this case handle itself?
local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ()) local function cleanup_ref(ref: string, lifetime: unknown, callback: () -> ())
@ -32,12 +38,20 @@ end
local function cleanup(callback: () -> ()) local function cleanup(callback: () -> ())
local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup` local lifetime = debug.info(2, "f") -- `caller of cleanup() is lifetime of cleanup`
if flags.strict then
local line = debug.info(2, "l") local line = debug.info(2, "l")
local cur_line = debug_caller_to_line[lifetime]
if cur_line and cur_line ~= line then
throw "only one cleanup call is allowed per function scope"
end
debug_caller_to_line[lifetime] = line
end
if manual_mode.caller == lifetime then if manual_mode.caller == lifetime then
table.insert(manual_mode.callbacks, callback) table.insert(manual_mode.callbacks, callback)
else else
local ref = tostring(lifetime) .. line local ref = tostring(lifetime)
cleanup_ref(ref, lifetime, callback) cleanup_ref(ref, lifetime, callback)
end end
end end

View file

@ -547,6 +547,26 @@ TEST("cleanup()", function()
CHECK(objB.cleaned == 2) CHECK(objB.cleaned == 2)
end end
-- this is not allowed, test to verify behavior anyways
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, { 1 }))
state(2)
CHECK(testkit.seq(queue, { 1, 2, 1 }))
state(3)
CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1 }))
end
--[[
do CASE "multiple cleanup" do CASE "multiple cleanup"
local state = source(1) local state = source(1)
@ -574,6 +594,7 @@ TEST("cleanup()", function()
--testkit.print2(queue) --testkit.print2(queue)
--CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1, 2 })) --CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1, 2 }))
end end
]]
end) end)
TEST("create()", function() TEST("create()", function()
@ -1308,6 +1329,7 @@ TEST("strict", function()
local derive = vide.derive local derive = vide.derive
local watch = vide.watch local watch = vide.watch
local indexes, values = vide.indexes, vide.values local indexes, values = vide.indexes, vide.values
local cleanup = vide.cleanup
do CASE "error on derived callback yield" do CASE "error on derived callback yield"
local state = source(1) local state = source(1)
@ -1410,6 +1432,15 @@ TEST("strict", function()
CHECK(ok) CHECK(ok)
end end
do CASE "multiple cleanup per scope"
local ok = pcall(function()
cleanup(function() end)
cleanup(function() end)
end)
CHECK(not ok)
end
end) end)
local ok = FINISH() local ok = FINISH()

View file

@ -1,7 +1,6 @@
# todo # todo
- cleanup codebase - cleanup codebase
- limit `cleanup()` call to once per function scope?
- better error reporting and stack traces in strict mode - better error reporting and stack traces in strict mode
- auto-enable of strict mode depending on compiler optimizaton level - auto-enable of strict mode depending on compiler optimizaton level
- check smoothness of spring at high frequency updates - check smoothness of spring at high frequency updates