@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 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.21.0",
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": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
42
+ "gitHead": "8677b70e89a1ef1585f8492b0638ec4a29aba8a3"
43
43
  }
@@ -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. A single empty table carries no more information than an
367
- -- empty rejection (aggregators may reject with an empty results table), so it is not
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 type(onRejected) == "function" then
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