@quenty/promise 10.21.0 → 10.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/src/Shared/Promise.lua +31 -8
- package/src/Shared/Promise.spec.lua +164 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [10.23.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.22.0...@quenty/promise@10.23.0) (2026-07-25)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Promises don't reject on Promise.rejected(nil) ([be63400](https://github.com/Quenty/NevermoreEngine/commit/be63400f89c863624b75b1903f7e8357ddca7bf9))
|
|
11
|
+
|
|
12
|
+
# [10.22.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.21.0...@quenty/promise@10.22.0) (2026-07-23)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- Even more stuff ([fea7e95](https://github.com/Quenty/NevermoreEngine/commit/fea7e9587e7195bbfa7f4753c130a3d597f9a34b))
|
|
17
|
+
|
|
6
18
|
# [10.21.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.20.0...@quenty/promise@10.21.0) (2026-07-23)
|
|
7
19
|
|
|
8
20
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/promise",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.23.0",
|
|
4
4
|
"description": "Promise implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -39,5 +39,5 @@
|
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
|
-
"gitHead": "
|
|
42
|
+
"gitHead": "8677b70e89a1ef1585f8492b0638ec4a29aba8a3"
|
|
43
43
|
}
|
package/src/Shared/Promise.lua
CHANGED
|
@@ -363,11 +363,8 @@ function Promise._reject<T...>(self: Promise<T...>, values: { n: number, [number
|
|
|
363
363
|
self:_executeThen(unpack(data))
|
|
364
364
|
end
|
|
365
365
|
|
|
366
|
-
-- Check for uncaught exceptions
|
|
367
|
-
|
|
368
|
-
-- reported either.
|
|
369
|
-
local isEmptyTableRejection = values.n == 1 and type(values[1]) == "table" and next(values[1]) == nil
|
|
370
|
-
if self._unconsumedException and values.n > 0 and not isEmptyTableRejection then
|
|
366
|
+
-- Check for uncaught exceptions
|
|
367
|
+
if self._unconsumedException and self:_isReportableRejection(values) then
|
|
371
368
|
task.defer(function()
|
|
372
369
|
-- Yield to end of frame, giving control back to Roblox.
|
|
373
370
|
-- This is the equivalent of giving something back to a task manager.
|
|
@@ -386,6 +383,23 @@ function Promise._reject<T...>(self: Promise<T...>, values: { n: number, [number
|
|
|
386
383
|
end
|
|
387
384
|
end
|
|
388
385
|
|
|
386
|
+
-- A rejection is only worth reporting if it carries information. Rejecting with no values at all
|
|
387
|
+
-- (what Destroy() and cancellation produce), with values that are all nil, or with a single empty
|
|
388
|
+
-- table (aggregators may reject with an empty results table) says nothing a warning could relay.
|
|
389
|
+
function Promise._isReportableRejection<T...>(_self: Promise<T...>, values: { n: number, [number]: any }): boolean
|
|
390
|
+
if values.n == 1 and type(values[1]) == "table" and next(values[1]) == nil then
|
|
391
|
+
return false
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
for index = 1, values.n do
|
|
395
|
+
if values[index] ~= nil then
|
|
396
|
+
return true
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
return false
|
|
401
|
+
end
|
|
402
|
+
|
|
389
403
|
function Promise._toHumanReadable<T...>(_self: Promise<T...>, data: any): string
|
|
390
404
|
if type(data) == "table" then
|
|
391
405
|
-- A custom __tostring is the best human-readable form; only address-form tables
|
|
@@ -433,15 +447,24 @@ function Promise.Then<T...>(
|
|
|
433
447
|
onFulfilled: ((T...) -> ...any)?,
|
|
434
448
|
onRejected: ((...any) -> ...any)?
|
|
435
449
|
): Promise<...any>
|
|
436
|
-
if
|
|
450
|
+
if self._pendingExecuteList then
|
|
451
|
+
-- Attaching any continuation delegates this promise's rejection to the returned
|
|
452
|
+
-- child promise, which becomes the new carrier of the unconsumed exception. So the
|
|
453
|
+
-- parent is consumed even without an onRejected here: a rejection flowing through a
|
|
454
|
+
-- handler-less Then is forwarded, not dropped, and the warning surfaces only at the
|
|
455
|
+
-- tail of the chain if nobody ever handles it.
|
|
437
456
|
self._unconsumedException = false
|
|
438
|
-
end
|
|
439
457
|
|
|
440
|
-
if self._pendingExecuteList then
|
|
441
458
|
local promise: Promise<T...> = Promise.new()
|
|
442
459
|
self._pendingExecuteList[#self._pendingExecuteList + 1] = { onFulfilled, onRejected, promise } :: { any }
|
|
443
460
|
return promise
|
|
444
461
|
else
|
|
462
|
+
-- Already settled: _executeThen returns self when there is no matching handler, so
|
|
463
|
+
-- self stays the carrier. Only an actual onRejected consumes the rejection here.
|
|
464
|
+
if type(onRejected) == "function" then
|
|
465
|
+
self._unconsumedException = false
|
|
466
|
+
end
|
|
467
|
+
|
|
445
468
|
return self:_executeThen(onFulfilled, onRejected, nil)
|
|
446
469
|
end
|
|
447
470
|
end
|
|
@@ -133,6 +133,25 @@ describe("Promise.rejected", function()
|
|
|
133
133
|
it("reuses a single shared promise when rejecting with no values", function()
|
|
134
134
|
expect(Promise.rejected()).toBe(Promise.rejected())
|
|
135
135
|
end)
|
|
136
|
+
|
|
137
|
+
it("rejects with nil without reporting an uncaught exception", function()
|
|
138
|
+
local promise = Promise.rejected(nil)
|
|
139
|
+
|
|
140
|
+
expect(promise:IsRejected()).toEqual(true)
|
|
141
|
+
expect(promise:_isReportableRejection(promise._rejected)).toEqual(false)
|
|
142
|
+
|
|
143
|
+
local ok, err = promise:GetResults()
|
|
144
|
+
expect(ok).toEqual(false)
|
|
145
|
+
expect(err).toEqual(nil)
|
|
146
|
+
end)
|
|
147
|
+
|
|
148
|
+
it("rejects with several nils without reporting an uncaught exception", function()
|
|
149
|
+
local promise = Promise.rejected(nil, nil, nil)
|
|
150
|
+
|
|
151
|
+
expect(promise:IsRejected()).toEqual(true)
|
|
152
|
+
expect(promise._rejected.n).toEqual(3)
|
|
153
|
+
expect(promise:_isReportableRejection(promise._rejected)).toEqual(false)
|
|
154
|
+
end)
|
|
136
155
|
end)
|
|
137
156
|
|
|
138
157
|
describe("Promise:Resolve", function()
|
|
@@ -324,6 +343,123 @@ describe("Promise:Then", function()
|
|
|
324
343
|
end)
|
|
325
344
|
end)
|
|
326
345
|
|
|
346
|
+
describe("Promise:Then unconsumed-exception propagation", function()
|
|
347
|
+
it("consumes a pending promise once any continuation is attached", function()
|
|
348
|
+
local root = Promise.new()
|
|
349
|
+
expect((root :: any)._unconsumedException).toEqual(true)
|
|
350
|
+
|
|
351
|
+
root:Then(function() end)
|
|
352
|
+
|
|
353
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
354
|
+
end)
|
|
355
|
+
|
|
356
|
+
it("transfers the unconsumed exception to the returned child", function()
|
|
357
|
+
local root = Promise.new()
|
|
358
|
+
local child = root:Then(function() end)
|
|
359
|
+
|
|
360
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
361
|
+
expect((child :: any)._unconsumedException).toEqual(true)
|
|
362
|
+
|
|
363
|
+
root:Reject("boom")
|
|
364
|
+
|
|
365
|
+
-- Consume the child before the deferred uncaught-exception warning fires.
|
|
366
|
+
child:Catch(function() end)
|
|
367
|
+
end)
|
|
368
|
+
|
|
369
|
+
it("propagates a rejection through handler-less Thens to a downstream handler", function()
|
|
370
|
+
local root = Promise.new()
|
|
371
|
+
local tail = root:Then(function() end):Then(function() end):Then(nil, function(err)
|
|
372
|
+
return "recovered from " .. err
|
|
373
|
+
end)
|
|
374
|
+
|
|
375
|
+
root:Reject("boom")
|
|
376
|
+
|
|
377
|
+
local outcome, value = PromiseTestUtils.awaitOutcome(tail)
|
|
378
|
+
expect(outcome).toEqual("resolved")
|
|
379
|
+
expect(value).toEqual("recovered from boom")
|
|
380
|
+
end)
|
|
381
|
+
|
|
382
|
+
it("consumes every intermediate hop so none of them warn", function()
|
|
383
|
+
local root = Promise.new()
|
|
384
|
+
local hop1 = root:Then(function() end)
|
|
385
|
+
local hop2 = hop1:Then(function() end)
|
|
386
|
+
hop2:Then(nil, function(err)
|
|
387
|
+
return err
|
|
388
|
+
end)
|
|
389
|
+
|
|
390
|
+
root:Reject("boom")
|
|
391
|
+
|
|
392
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
393
|
+
expect((hop1 :: any)._unconsumedException).toEqual(false)
|
|
394
|
+
expect((hop2 :: any)._unconsumedException).toEqual(false)
|
|
395
|
+
end)
|
|
396
|
+
|
|
397
|
+
it("leaves an unhandled tail flagged so the error is not silently dropped", function()
|
|
398
|
+
local root = Promise.new()
|
|
399
|
+
local tail = root:Then(function() end):Then(function() end)
|
|
400
|
+
|
|
401
|
+
root:Reject("boom")
|
|
402
|
+
|
|
403
|
+
expect(tail:IsRejected()).toEqual(true)
|
|
404
|
+
expect((tail :: any)._unconsumedException).toEqual(true)
|
|
405
|
+
|
|
406
|
+
-- Consume it before the deferred uncaught-exception warning fires.
|
|
407
|
+
tail:Catch(function() end)
|
|
408
|
+
expect((tail :: any)._unconsumedException).toEqual(false)
|
|
409
|
+
end)
|
|
410
|
+
|
|
411
|
+
it("gives each fan-out branch its own unconsumed carrier", function()
|
|
412
|
+
local root = Promise.new()
|
|
413
|
+
local branchA = root:Then(function() end)
|
|
414
|
+
local branchB = root:Then(function() end)
|
|
415
|
+
|
|
416
|
+
root:Reject("boom")
|
|
417
|
+
|
|
418
|
+
expect((branchA :: any)._unconsumedException).toEqual(true)
|
|
419
|
+
expect((branchB :: any)._unconsumedException).toEqual(true)
|
|
420
|
+
|
|
421
|
+
branchA:Catch(function() end)
|
|
422
|
+
branchB:Catch(function() end)
|
|
423
|
+
end)
|
|
424
|
+
|
|
425
|
+
it("does not consume an already-rejected source when onRejected is omitted", function()
|
|
426
|
+
local source = Promise.rejected("nope")
|
|
427
|
+
-- Settled + no onRejected returns self, so self stays the carrier and must remain flagged.
|
|
428
|
+
local same = source:Then(function() end)
|
|
429
|
+
|
|
430
|
+
expect(same == source).toEqual(true)
|
|
431
|
+
expect((source :: any)._unconsumedException).toEqual(true)
|
|
432
|
+
|
|
433
|
+
source:Catch(function() end)
|
|
434
|
+
expect((source :: any)._unconsumedException).toEqual(false)
|
|
435
|
+
end)
|
|
436
|
+
|
|
437
|
+
it("runs the tail error handler for the datastore graceful-close shape", function()
|
|
438
|
+
local outer = Promise.new()
|
|
439
|
+
local root = Promise.new()
|
|
440
|
+
|
|
441
|
+
root:Then(function()
|
|
442
|
+
return "unused"
|
|
443
|
+
end)
|
|
444
|
+
:Then(function()
|
|
445
|
+
return "unused"
|
|
446
|
+
end)
|
|
447
|
+
:Then(function(value)
|
|
448
|
+
outer:Resolve(value)
|
|
449
|
+
end, function(err)
|
|
450
|
+
outer:Reject(string.format("locked: %s", err))
|
|
451
|
+
end)
|
|
452
|
+
|
|
453
|
+
root:Reject("still locked")
|
|
454
|
+
|
|
455
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(outer)
|
|
456
|
+
expect(outcome).toEqual("rejected")
|
|
457
|
+
expect(err).toEqual("locked: still locked")
|
|
458
|
+
|
|
459
|
+
expect((root :: any)._unconsumedException).toEqual(false)
|
|
460
|
+
end)
|
|
461
|
+
end)
|
|
462
|
+
|
|
327
463
|
describe("Promise:Catch", function()
|
|
328
464
|
it("handles a rejection", function()
|
|
329
465
|
local outcome, value = PromiseTestUtils.awaitOutcome(Promise.rejected("caught"):Catch(function(err)
|
|
@@ -414,6 +550,34 @@ describe("Promise:GetResults", function()
|
|
|
414
550
|
end)
|
|
415
551
|
end)
|
|
416
552
|
|
|
553
|
+
describe("Promise._isReportableRejection", function()
|
|
554
|
+
local promise = Promise.new()
|
|
555
|
+
|
|
556
|
+
it("reports a rejection that carries a value", function()
|
|
557
|
+
expect(promise:_isReportableRejection(table.pack("boom"))).toEqual(true)
|
|
558
|
+
expect(promise:_isReportableRejection(table.pack(false))).toEqual(true)
|
|
559
|
+
expect(promise:_isReportableRejection(table.pack(0))).toEqual(true)
|
|
560
|
+
expect(promise:_isReportableRejection(table.pack({ code = 500 }))).toEqual(true)
|
|
561
|
+
end)
|
|
562
|
+
|
|
563
|
+
it("reports a rejection whose value is not the first one", function()
|
|
564
|
+
expect(promise:_isReportableRejection(table.pack(nil, "boom"))).toEqual(true)
|
|
565
|
+
end)
|
|
566
|
+
|
|
567
|
+
it("does not report a rejection with no values", function()
|
|
568
|
+
expect(promise:_isReportableRejection({ n = 0 })).toEqual(false)
|
|
569
|
+
end)
|
|
570
|
+
|
|
571
|
+
it("does not report a rejection whose values are all nil", function()
|
|
572
|
+
expect(promise:_isReportableRejection(table.pack(nil))).toEqual(false)
|
|
573
|
+
expect(promise:_isReportableRejection(table.pack(nil, nil, nil))).toEqual(false)
|
|
574
|
+
end)
|
|
575
|
+
|
|
576
|
+
it("does not report a rejection carrying a single empty table", function()
|
|
577
|
+
expect(promise:_isReportableRejection(table.pack({}))).toEqual(false)
|
|
578
|
+
end)
|
|
579
|
+
end)
|
|
580
|
+
|
|
417
581
|
describe("Promise._toHumanReadable", function()
|
|
418
582
|
local promise = Promise.new()
|
|
419
583
|
|