Probably not as much of a scathing review as @AskWisp but it is a bit frustrating to use for two reasons:
- The internal listener doesn’t get cleaned up when an object is destroyed due to Roblox wanting to support legacy behaviour, discussed in this engine bug report here and here, e.g.:
--[!] Will yield for 5s despite being destroyed
local part = Instance.new('Part')
task.delay(1, part.Destroy, part)
local started = os.clock()
local something = part:WaitForChild('Something', 5)
local elapsed = os.clock() - started
print(string.format('Result: %q | Time yielded: %.2fs', tostring(something), elapsed))
--[!] Will throw an infinite yield warning despite being destroyed
local destroyable = Instance.new('Part')
task.delay(1, destroyable.Destroy, destroyable)
local item = destroyable:WaitForChild('Something')
print('We will never get to this point....')
- There doesn’t seem to be a method of being able to cancel or cleanup the listeners, even if you cancel/close the thread that started the listener. There’s some ambiguity as to whether it’s just the internal timer that’s not cleaned up, or whether the waiting thread is also leaked, e.g.:
--[!] Will still send a warning of `Infinite yield possible on 'Workspace:WaitForChild("SomePart")'`
local thread = task.spawn(function ()
local someExpectedObject = workspace:WaitForChild('SomePart')
end)
task.defer(task.cancel, thread)
I know OP doesn’t want alternatives, but for reference:
Roblox uses their AtomicBinding
library to achieve the desired behaviour OP discussed, found here and utilised within the RbxCharacterSounds
script seen here.
Alternatively, I usually just utilise promises, as well as a maid class for cleanup, e.g. Ozzypig’s maid.
For example, if I wanted to support the desired API that OP has described…
Example method using a Promise
local Maid = require(some.maid.library)
local Promise = require(some.promise.library)
----------------------------------------------
-- --
-- METHOD --
-- --
----------------------------------------------
--[=[
Creates a promise that resolves with the asset when found,
or rejects if the parent is no longer accessible.
- Can be yielded using `::await()`
- Can be timed out using `::timeout(n: number)`
e.g.
expectAsset(workspace, 'SomePart')
:andThen(function (obj)
-- do something
end)
:catch(function (e)
-- handle error(s)
end)
@param parent Instance -- root node of the asset path
@param ... string -- variadic arguments of type `string`
@return Promise
]=]
local function expectAsset(parent, ...)
local t = typeof(parent)
if t ~= 'Instance' then
return Promise.reject(string.format('Expected instance for parent, got %q', t))
end
local asset = parent
local length = select('#', ...)
for i = 1, length, 1 do
if not asset then
break
end
local name = select(i, ...)
t = typeof(name)
if t ~= 'string' then
return Promise.reject(string.format(
'Expected string for path element, got %q at index %d in Path<%s>',
t, i, table.concat(table.pack(...), '.')
))
end
asset = asset:FindFirstChild(name)
end
if asset then
return Promise.resolve(asset)
end
local path = table.pack(...)
return Promise.new(function (resolve, reject, onCancel)
local maid = Maid.new()
onCancel(function ()
maid:cleanup()
end)
local function progress(fn, ...)
maid:cleanup()
fn(...)
end
maid:addTask(
parent.Destroying:Connect(function ()
progress(reject, string.format(
'Parent<%s> destroyed before finding asset in Path<%s>',
parent and parent.Name or 'UNKNOWN', table.concat(path, '.')
))
end)
)
maid:addTask(
parent.DescendantAdded:Connect(function ()
-- [!] Note:
-- could optimise this to start observing the next child instead of the root node
local object = parent
for i = 1, length, 1 do
if not object then
return
end
object = object:FindFirstChild(path[i])
end
progress(resolve, object)
end)
)
end)
end
Example usage of the method
----------------------------------------------
-- --
-- USAGE --
-- --
----------------------------------------------
local function someErrorHandler(result)
result = typeof(result) == 'string' and result or 'Unknown error occurred:\n' .. tostring(result)
warn(result)
end
-- e.g. yielding
local success, result = expectAsset(workspace, 'SomeModel', 'SomePart'):await()
if success then
print('Got:', result)
else
someErrorHandler(result)
end
-- e.g. yielding with timeout
local success, result = expectAsset(workspace, 'SomeModel', 'SomePart'):timeout(1):await()
if success then
print('Got:', result)
else
someErrorHandler(result)
end
-- e.g. as a promise, and an example of cancelling the waiting thread & listeners
local cancellable
cancellable = expectAsset(workspace, 'SomeModel', 'SomePart')
:andThen(function (object)
-- do something with the part
end)
:catch(error)
:finally(function ()
if cancellable and cancellable:getStatus() == Promise.Status.Cancelled then
warn('We were cancelled!')
end
end)
-- e.g. example cancellation of the promise to clean up listeners
task.delay(1, function ()
if cancellable and cancellable:getStatus() == Promise.Status.Started then
cancellable:cancel()
end
end)
-- e.g. promise with a timeout of some arbitrary time like 2seconds
expectAsset(workspace, 'SomeModel', 'SomePart')
:timeout(2)
:andThen(function (object)
-- do something with the part
end)
:catch(function (e)
if Promise.Error.isKind(e, Promise.Error.Kind.TimedOut) then
warn('We timed out after 2s of waiting for workspace.SomeModel.SomePart')
return
end
-- e.g. otherwise some other error occurred
return error(e)
end)