Fix scope stack staying broken after a yield in strict mode (#105)
Some checks are pending
unit-test / unit-test (push) Waiting to run

* Fix scope stack staying broken after a yield in strict mode

* Warn on a yield in strict mode instead of cancelling the thread

* Fix type error when an effect errors outside strict mode
This commit is contained in:
Kaan 2026-10-22 15:56:38 +03:00 • committed by GitHub
parent d99dbe1873
commit 033d30fbfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 51 additions and 12 deletions

View file

@ -15,7 +15,7 @@ and identifying improper usage.
Currently, strict mode will: Currently, strict mode will:
1. Run reactive scopes twice when a source updates. 1. Run reactive scopes twice when a source updates.
2. Throw an error if yields occur where they are not allowed. 2. Warn if yields occur where they are not allowed.
3. Checks for `values()` input having duplicate values. 3. Checks for `values()` input having duplicate values.
4. Checks for duplicate nested properties at same depth. 4. Checks for duplicate nested properties at same depth.
5. Checks for destruction of an active scope. 5. Checks for destruction of an active scope.

View file

@ -1,5 +1,7 @@
local flags = require "./flags" local flags = require "./flags"
local warn = warn or print
export type SourceNode<T> = { export type SourceNode<T> = {
cache: T, cache: T,
[number]: Node<T> [number]: Node<T>
@ -42,19 +44,33 @@ local function effect_traceback(err: unknown): string
return `effect error:\n{debug.traceback(message, 2)}` return `effect error:\n{debug.traceback(message, 2)}`
end end
local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U) -- also returns whether fn yielded, its thread is then left to finish on its own
local function ycall<T, U>(fn: (T) -> U, arg: T): (boolean, string|U, boolean)
local thread = coroutine.create(xpcall) local n = scopes.n
local yielded = false
local thread = coroutine.create(function()
local ok, result = xpcall(fn, efn, arg)
if yielded and not ok then error(result, 0) end -- no caller left to return it to
return ok, result
end)
--local function efn(err: string) return debug.traceback(err, 3) end --local function efn(err: string) return debug.traceback(err, 3) end
local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) local resume_ok, run_ok, result = coroutine.resume(thread)
assert(resume_ok) assert(resume_ok)
if coroutine.status(thread) ~= "dead" then if coroutine.status(thread) ~= "dead" then
return false, debug.traceback(thread, "attempt to yield in reactive scope") yielded = true
warn(debug.traceback(thread, "attempt to yield in reactive scope"))
-- the thread pops the scopes it pushed once resumed, so move the
-- caller's scope above them for the caller to pop
table.insert(scopes, table.remove(scopes, n) :: Node<any>)
return true, result, true
end end
return run_ok, result return run_ok, result, false
end end
local function get_scope(): Node<unknown>? local function get_scope(): Node<unknown>?
@ -190,9 +206,11 @@ local function try_evaluate_node<T>(node: Node<T>): (boolean, string?)
err = err or cleanup_err or owned_err err = err or cleanup_err or owned_err
push_scope(node) push_scope(node)
local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) local ok, new_value, yielded = ycall(node.effect :: (T) -> T, cur_value)
pop_scope() pop_scope()
if yielded then break end
if not ok then if not ok then
return false, `effect error stacktrace\n{new_value :: string}` return false, `effect error stacktrace\n{new_value :: string}`
end end
@ -212,7 +230,7 @@ local function try_evaluate_node<T>(node: Node<T>): (boolean, string?)
pop_scope() pop_scope()
if not ok then if not ok then
return false, new_value return false, new_value :: any
end end
node.cache = new_value node.cache = new_value

View file

@ -3146,7 +3146,7 @@ end))
TEST("strict", wrap_root(function() TEST("strict", wrap_root(function()
vide.strict = true vide.strict = true
do CASE "error on derived callback yield" do CASE "warn on derived callback yield"
local src = source(1) local src = source(1)
local ok = pcall(function() local ok = pcall(function()
@ -3156,10 +3156,10 @@ TEST("strict", wrap_root(function()
end) end)
end) end)
CHECK(not ok) CHECK(ok)
end end
do CASE "error on effecter callback yield" do CASE "warn on effecter callback yield"
local src = source(1) local src = source(1)
local ok = pcall(function() local ok = pcall(function()
@ -3169,7 +3169,28 @@ TEST("strict", wrap_root(function()
end) end)
end) end)
CHECK(not ok) CHECK(ok)
end
do CASE "yield in a component"
local input = source(false)
local thread
local output
local destroy = root(function()
output = show(input, function()
thread = coroutine.running()
coroutine.yield()
return "component"
end)
end)
CHECK((pcall(input, true)))
CHECK(output() == nil)
CHECK((coroutine.resume(thread)))
CHECK(output() == "component")
CHECK(pcall(input, false))
CHECK(pcall(destroy))
end end
do CASE "run derived callback twice" do CASE "run derived callback twice"