mirror of
https://github.com/AmberGraceRblx/luau-promise.git
synced 2025-04-24 15:50:01 +00:00
parent
ff345ea31b
commit
faa4f73dd3
5 changed files with 171 additions and 57 deletions
|
@ -1,6 +1,7 @@
|
|||
# Next
|
||||
|
||||
- Runtime errors are now represented by objects. You must call tostring on rejection values before assuming they are strings (this was always good practice, but is required now).
|
||||
- New Promise Error class is exposed at `Promise.Error`, which includes helpful static methods like `Promise.Error.is`.
|
||||
- Yielding is now allowed in Promise.new, andThen, and Promise.try executors.
|
||||
- Errors now have much better stack traces due to using xpcall internally instead of pcall.
|
||||
- Stack traces now be more direct and not include as many internal calls within the Promise library.
|
||||
|
@ -10,6 +11,9 @@
|
|||
- Improve test coverage for asynchronous and time-driven functions
|
||||
- Change Promise.is to be safe when dealing with tables that have an `__index` metamethod that creates an error.
|
||||
- Let Promise:expect() throw rejection objects
|
||||
- Add Promise:now() (#23)
|
||||
- Promise:timeout() now rejects with a `Promise.Error(Promise.Error.Kind.TimedOut)` object. (Formerly rejected with the string "Timed out")
|
||||
- Attaching a handler to a cancelled Promise now rejects with a `Promise.Error(Promise.Error.Kind.AlreadyCancelled)`. (Formerly rejected with the string "Promise is cancelled")
|
||||
|
||||
# 2.5.1
|
||||
|
||||
|
|
|
@ -512,16 +512,18 @@ docs:
|
|||
returns: Promise<...any?>
|
||||
|
||||
- name: timeout
|
||||
params: "seconds: number, rejectionValue: any?"
|
||||
params: "seconds: number, rejectionValue: T?"
|
||||
desc: |
|
||||
Returns a new Promise that resolves if the chained Promise resolves within `seconds` seconds, or rejects if execution time exceeds `seconds`. The chained Promise will be cancelled if the timeout is reached.
|
||||
|
||||
Rejects with `rejectionValue` if it is non-nil. If a `rejectionValue` is not given, it will reject with a `Promise.Error(Promise.Error.Kind.TimedOut)`. This can be checked with [[Error.isKind]].
|
||||
|
||||
Sugar for:
|
||||
|
||||
```lua
|
||||
Promise.race({
|
||||
Promise.delay(seconds):andThen(function()
|
||||
return Promise.reject(rejectionValue == nil and "Timed out" or rejectionValue)
|
||||
return Promise.reject(rejectionValue == nil and Promise.Error.new({ kind = Promise.Error.Kind.TimedOut }) or rejectionValue)
|
||||
end),
|
||||
promise
|
||||
})
|
||||
|
@ -536,6 +538,22 @@ docs:
|
|||
|
||||
Promises will only be cancelled if all of their consumers are also cancelled. This is to say that if you call `andThen` twice on the same promise, and you cancel only one of the child promises, it will not cancel the parent promise until the other child promise is also cancelled.
|
||||
|
||||
- name: now
|
||||
desc: |
|
||||
Chains a Promise from this one that is resolved if this Promise is already resolved, and rejected if it is not resolved at the time of calling `:now()`. This can be used to ensure your `andThen` handler occurs on the same frame as the root Promise execution.
|
||||
|
||||
```lua
|
||||
doSomething()
|
||||
:now()
|
||||
:andThen(function(value)
|
||||
print("Got", value, "synchronously.")
|
||||
end)
|
||||
```
|
||||
|
||||
If this Promise is still running, Rejected, or Cancelled, the Promise returned from `:now()` will reject with the `rejectionValue` if passed, or a `Promise.Error(Promise.Error.Kind.NotResolvedInTime)`. This can be checked with [[Error.isKind]].
|
||||
params: "rejectionValue: T?"
|
||||
returns: Promise<T>
|
||||
|
||||
- name: await
|
||||
tags: [ 'yields' ]
|
||||
desc: |
|
||||
|
|
|
@ -152,7 +152,7 @@ It's good practice to add an `onCancel` hook to all of your asynchronous Promise
|
|||
Even if you don't plan to directly cancel a particular Promise, chaining with other Promises can cause it to become automatically cancelled if no one cares about the value anymore.
|
||||
:::
|
||||
|
||||
If you attach a `:andThen` or `:catch` handler to a Promise after it's been cancelled, the chained Promise will be instantly rejected with the error "Promise is cancelled". This also applies to Promises that you pass to `resolve`. However, `finally` does not have this constraint.
|
||||
If you attach a `:andThen` or `:catch` handler to a Promise after it's been cancelled, the chained Promise will be instantly rejected with `Promise.Error(Promise.Error.Kind.AlreadyCancelled)`. This also applies to Promises that you pass to `resolve`. However, `finally` does not have this constraint.
|
||||
|
||||
::: warning
|
||||
If you cancel a Promise immediately after creating it without yielding in between, the fate of the Promise is dependent on if the Promise handler yields or not. If the Promise handler resolves without yielding, then the Promise will already be settled by the time you are able to cancel it, thus any consumers of the Promise will have already been called and cancellation is not possible.
|
||||
|
|
177
lib/init.lua
177
lib/init.lua
|
@ -12,60 +12,105 @@ local MODE_KEY_METATABLE = {
|
|||
|
||||
local RunService = game:GetService("RunService")
|
||||
|
||||
--[[
|
||||
Creates an enum dictionary with some metamethods to prevent common mistakes.
|
||||
]]
|
||||
local function makeEnum(enumName, members)
|
||||
local enum = {}
|
||||
|
||||
for _, memberName in ipairs(members) do
|
||||
enum[memberName] = memberName
|
||||
end
|
||||
|
||||
return setmetatable(enum, {
|
||||
__index = function(_, k)
|
||||
error(("%s is not in %s!"):format(k, enumName), 2)
|
||||
end,
|
||||
__newindex = function()
|
||||
error(("Creating new members in %s is not allowed!"):format(enumName), 2)
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
--[[
|
||||
An object to represent runtime errors that occur during execution.
|
||||
Promises that experience an error like this will be rejected with
|
||||
an instance of this object.
|
||||
]]
|
||||
local RuntimeError = {}
|
||||
RuntimeError.__index = RuntimeError
|
||||
local Error do
|
||||
Error = {
|
||||
Kind = makeEnum("Promise.Error.Kind", {
|
||||
"ExecutionError",
|
||||
"AlreadyCancelled",
|
||||
"NotResolvedInTime",
|
||||
"TimedOut"
|
||||
})
|
||||
}
|
||||
Error.__index = Error
|
||||
|
||||
function RuntimeError.new(options, parent)
|
||||
options = options or {}
|
||||
return setmetatable({
|
||||
error = tostring(options.error) or "[This error has no error text.]",
|
||||
trace = options.trace,
|
||||
context = options.context,
|
||||
parent = parent,
|
||||
createdTick = tick(),
|
||||
createdTrace = debug.traceback()
|
||||
}, RuntimeError)
|
||||
end
|
||||
function Error.new(options, parent)
|
||||
options = options or {}
|
||||
return setmetatable({
|
||||
error = tostring(options.error) or "[This error has no error text.]",
|
||||
trace = options.trace,
|
||||
context = options.context,
|
||||
kind = options.kind,
|
||||
parent = parent,
|
||||
createdTick = tick(),
|
||||
createdTrace = debug.traceback()
|
||||
}, Error)
|
||||
end
|
||||
|
||||
function RuntimeError.is(anything)
|
||||
if type(anything) == "table" then
|
||||
local metatable = getmetatable(anything)
|
||||
function Error.is(anything)
|
||||
if type(anything) == "table" then
|
||||
local metatable = getmetatable(anything)
|
||||
|
||||
if type(metatable) == "table" then
|
||||
return rawget(anything, "error") ~= nil and type(rawget(metatable, "extend")) == "function"
|
||||
if type(metatable) == "table" then
|
||||
return rawget(anything, "error") ~= nil and type(rawget(metatable, "extend")) == "function"
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
function Error.isKind(anything, kind)
|
||||
assert(kind ~= nil, "Argument #2 to Promise.Error.isKind must not be nil")
|
||||
|
||||
function RuntimeError:extend(options)
|
||||
return RuntimeError.new(options, self)
|
||||
end
|
||||
|
||||
function RuntimeError:getErrorChain()
|
||||
local runtimeErrors = { self }
|
||||
|
||||
while runtimeErrors[#runtimeErrors].parent do
|
||||
table.insert(runtimeErrors, runtimeErrors[#runtimeErrors].parent)
|
||||
return Error.is(anything) and anything.kind == kind
|
||||
end
|
||||
|
||||
return runtimeErrors
|
||||
end
|
||||
function Error:extend(options)
|
||||
options = options or {}
|
||||
|
||||
function RuntimeError:__tostring()
|
||||
local errorStrings = {}
|
||||
options.kind = options.kind or self.kind
|
||||
|
||||
for _, runtimeError in ipairs(self:getErrorChain()) do
|
||||
table.insert(errorStrings, table.concat({runtimeError.trace or runtimeError.error, runtimeError.context}, "\n"))
|
||||
return Error.new(options, self)
|
||||
end
|
||||
|
||||
return table.concat(errorStrings, "\n")
|
||||
function Error:getErrorChain()
|
||||
local runtimeErrors = { self }
|
||||
|
||||
while runtimeErrors[#runtimeErrors].parent do
|
||||
table.insert(runtimeErrors, runtimeErrors[#runtimeErrors].parent)
|
||||
end
|
||||
|
||||
return runtimeErrors
|
||||
end
|
||||
|
||||
function Error:__tostring()
|
||||
local errorStrings = {
|
||||
("-- Promise.Error(%s) --"):format(self.kind or "?"),
|
||||
}
|
||||
|
||||
for _, runtimeError in ipairs(self:getErrorChain()) do
|
||||
table.insert(errorStrings, table.concat({
|
||||
runtimeError.trace or runtimeError.error,
|
||||
runtimeError.context
|
||||
}, "\n"))
|
||||
end
|
||||
|
||||
return table.concat(errorStrings, "\n")
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
|
@ -87,8 +132,9 @@ end
|
|||
|
||||
local function makeErrorHandler(traceback)
|
||||
return function(err)
|
||||
return RuntimeError.new({
|
||||
return Error.new({
|
||||
error = err,
|
||||
kind = Error.Kind.ExecutionError,
|
||||
trace = debug.traceback(err, 2),
|
||||
context = "Promise created at:\n\n" .. traceback
|
||||
})
|
||||
|
@ -123,24 +169,14 @@ local function isEmpty(t)
|
|||
end
|
||||
|
||||
local Promise = {
|
||||
RuntimeError = RuntimeError,
|
||||
Error = Error,
|
||||
Status = makeEnum("Promise.Status", {"Started", "Resolved", "Rejected", "Cancelled"}),
|
||||
_timeEvent = RunService.Heartbeat,
|
||||
_getTime = tick,
|
||||
}
|
||||
Promise.prototype = {}
|
||||
Promise.__index = Promise.prototype
|
||||
|
||||
Promise.Status = setmetatable({
|
||||
Started = "Started",
|
||||
Resolved = "Resolved",
|
||||
Rejected = "Rejected",
|
||||
Cancelled = "Cancelled",
|
||||
}, {
|
||||
__index = function(_, k)
|
||||
error(("%s is not in Promise.Status!"):format(k), 2)
|
||||
end
|
||||
})
|
||||
|
||||
--[[
|
||||
Constructs a new Promise with the given initializing callback.
|
||||
|
||||
|
@ -654,10 +690,19 @@ end
|
|||
--[[
|
||||
Rejects the promise after `seconds` seconds.
|
||||
]]
|
||||
function Promise.prototype:timeout(seconds, timeoutValue)
|
||||
function Promise.prototype:timeout(seconds, rejectionValue)
|
||||
local traceback = debug.traceback(nil, 2)
|
||||
|
||||
return Promise.race({
|
||||
Promise.delay(seconds):andThen(function()
|
||||
return Promise.reject(timeoutValue == nil and "Timed out" or timeoutValue)
|
||||
return Promise.reject(rejectionValue == nil and Error.new({
|
||||
kind = Error.Kind.TimedOut,
|
||||
error = "Timed out",
|
||||
context = ("Timeout of %d seconds exceeded.\n:timeout() called at:\n\n%s"):format(
|
||||
seconds,
|
||||
traceback
|
||||
)
|
||||
}) or rejectionValue)
|
||||
end),
|
||||
self
|
||||
})
|
||||
|
@ -713,7 +758,11 @@ function Promise.prototype:_andThen(traceback, successHandler, failureHandler)
|
|||
elseif self._status == Promise.Status.Cancelled then
|
||||
-- We don't want to call the success handler or the failure handler,
|
||||
-- we just reject this promise outright.
|
||||
reject("Promise is cancelled")
|
||||
reject(Error.new({
|
||||
error = "Promise is cancelled",
|
||||
kind = Error.Kind.AlreadyCancelled,
|
||||
context = "Promise created at\n\n" .. traceback
|
||||
}))
|
||||
end
|
||||
end, self)
|
||||
end
|
||||
|
@ -1035,13 +1084,14 @@ function Promise.prototype:_resolve(...)
|
|||
|
||||
-- Backwards compatibility < v2
|
||||
if chainedPromise._error then
|
||||
maybeRuntimeError = RuntimeError.new({
|
||||
maybeRuntimeError = Error.new({
|
||||
error = chainedPromise._error,
|
||||
kind = Error.Kind.ExecutionError,
|
||||
context = "[No stack trace available as this Promise originated from an older version of the Promise library (< v2)]"
|
||||
})
|
||||
end
|
||||
|
||||
if RuntimeError.is(maybeRuntimeError) then
|
||||
if Error.isKind(maybeRuntimeError, Error.Kind.ExecutionError) then
|
||||
return self:_reject(maybeRuntimeError:extend({
|
||||
error = "This Promise was chained to a Promise that errored.",
|
||||
trace = "",
|
||||
|
@ -1145,4 +1195,23 @@ function Promise.prototype:_finalize()
|
|||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Chains a Promise from this one that is resolved if this Promise is
|
||||
resolved, and rejected if it is not resolved.
|
||||
]]
|
||||
function Promise.prototype:now(rejectionValue)
|
||||
local traceback = debug.traceback(nil, 2)
|
||||
if self:getStatus() == Promise.Status.Resolved then
|
||||
return self:_andThen(traceback, function(...)
|
||||
return ...
|
||||
end)
|
||||
else
|
||||
return Promise.reject(rejectionValue == nil and Error.new({
|
||||
kind = Error.Kind.NotResolvedInTime,
|
||||
error = "This Promise was not resolved in time for :now()",
|
||||
context = ":now() was called at:\n\n" .. traceback
|
||||
}) or rejectionValue)
|
||||
end
|
||||
end
|
||||
|
||||
return Promise
|
||||
|
|
|
@ -1203,4 +1203,27 @@ return function()
|
|||
expect(value).to.equal(rejectionValue)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Promise:now", function()
|
||||
it("should resolve if the Promise is resolved", function()
|
||||
local success, value = Promise.resolve("foo"):now():_unwrap()
|
||||
|
||||
expect(success).to.equal(true)
|
||||
expect(value).to.equal("foo")
|
||||
end)
|
||||
|
||||
it("should reject if the Promise is not resolved", function()
|
||||
local success, value = Promise.new(function() end):now():_unwrap()
|
||||
|
||||
expect(success).to.equal(false)
|
||||
expect(Promise.Error.isKind(value, "NotResolvedInTime")).to.equal(true)
|
||||
end)
|
||||
|
||||
it("should reject with a custom rejection value", function()
|
||||
local success, value = Promise.new(function() end):now("foo"):_unwrap()
|
||||
|
||||
expect(success).to.equal(false)
|
||||
expect(value).to.equal("foo")
|
||||
end)
|
||||
end)
|
||||
end
|
Loading…
Reference in a new issue