@quenty/promise 10.21.0 → 10.22.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,12 @@
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.22.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.21.0...@quenty/promise@10.22.0) (2026-07-23)
7
+
8
+ ### Features
9
+
10
+ - Even more stuff ([fea7e95](https://github.com/Quenty/NevermoreEngine/commit/fea7e9587e7195bbfa7f4753c130a3d597f9a34b))
11
+
6
12
  # [10.21.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/promise@10.20.0...@quenty/promise@10.21.0) (2026-07-23)
7
13
 
8
14
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/promise",
3
- "version": "10.21.0",
3
+ "version": "10.22.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": "88b03743718172ff79cd62ade64a86565fcac17d"
43
43
  }
@@ -433,15 +433,24 @@ function Promise.Then<T...>(
433
433
  onFulfilled: ((T...) -> ...any)?,
434
434
  onRejected: ((...any) -> ...any)?
435
435
  ): Promise<...any>
436
- if type(onRejected) == "function" then
436
+ if self._pendingExecuteList then
437
+ -- Attaching any continuation delegates this promise's rejection to the returned
438
+ -- child promise, which becomes the new carrier of the unconsumed exception. So the
439
+ -- parent is consumed even without an onRejected here: a rejection flowing through a
440
+ -- handler-less Then is forwarded, not dropped, and the warning surfaces only at the
441
+ -- tail of the chain if nobody ever handles it.
437
442
  self._unconsumedException = false
438
- end
439
443
 
440
- if self._pendingExecuteList then
441
444
  local promise: Promise<T...> = Promise.new()
442
445
  self._pendingExecuteList[#self._pendingExecuteList + 1] = { onFulfilled, onRejected, promise } :: { any }
443
446
  return promise
444
447
  else
448
+ -- Already settled: _executeThen returns self when there is no matching handler, so
449
+ -- self stays the carrier. Only an actual onRejected consumes the rejection here.
450
+ if type(onRejected) == "function" then
451
+ self._unconsumedException = false
452
+ end
453
+
445
454
  return self:_executeThen(onFulfilled, onRejected, nil)
446
455
  end
447
456
  end
@@ -324,6 +324,123 @@ describe("Promise:Then", function()
324
324
  end)
325
325
  end)
326
326
 
327
+ describe("Promise:Then unconsumed-exception propagation", function()
328
+ it("consumes a pending promise once any continuation is attached", function()
329
+ local root = Promise.new()
330
+ expect((root :: any)._unconsumedException).toEqual(true)
331
+
332
+ root:Then(function() end)
333
+
334
+ expect((root :: any)._unconsumedException).toEqual(false)
335
+ end)
336
+
337
+ it("transfers the unconsumed exception to the returned child", function()
338
+ local root = Promise.new()
339
+ local child = root:Then(function() end)
340
+
341
+ expect((root :: any)._unconsumedException).toEqual(false)
342
+ expect((child :: any)._unconsumedException).toEqual(true)
343
+
344
+ root:Reject("boom")
345
+
346
+ -- Consume the child before the deferred uncaught-exception warning fires.
347
+ child:Catch(function() end)
348
+ end)
349
+
350
+ it("propagates a rejection through handler-less Thens to a downstream handler", function()
351
+ local root = Promise.new()
352
+ local tail = root:Then(function() end):Then(function() end):Then(nil, function(err)
353
+ return "recovered from " .. err
354
+ end)
355
+
356
+ root:Reject("boom")
357
+
358
+ local outcome, value = PromiseTestUtils.awaitOutcome(tail)
359
+ expect(outcome).toEqual("resolved")
360
+ expect(value).toEqual("recovered from boom")
361
+ end)
362
+
363
+ it("consumes every intermediate hop so none of them warn", function()
364
+ local root = Promise.new()
365
+ local hop1 = root:Then(function() end)
366
+ local hop2 = hop1:Then(function() end)
367
+ hop2:Then(nil, function(err)
368
+ return err
369
+ end)
370
+
371
+ root:Reject("boom")
372
+
373
+ expect((root :: any)._unconsumedException).toEqual(false)
374
+ expect((hop1 :: any)._unconsumedException).toEqual(false)
375
+ expect((hop2 :: any)._unconsumedException).toEqual(false)
376
+ end)
377
+
378
+ it("leaves an unhandled tail flagged so the error is not silently dropped", function()
379
+ local root = Promise.new()
380
+ local tail = root:Then(function() end):Then(function() end)
381
+
382
+ root:Reject("boom")
383
+
384
+ expect(tail:IsRejected()).toEqual(true)
385
+ expect((tail :: any)._unconsumedException).toEqual(true)
386
+
387
+ -- Consume it before the deferred uncaught-exception warning fires.
388
+ tail:Catch(function() end)
389
+ expect((tail :: any)._unconsumedException).toEqual(false)
390
+ end)
391
+
392
+ it("gives each fan-out branch its own unconsumed carrier", function()
393
+ local root = Promise.new()
394
+ local branchA = root:Then(function() end)
395
+ local branchB = root:Then(function() end)
396
+
397
+ root:Reject("boom")
398
+
399
+ expect((branchA :: any)._unconsumedException).toEqual(true)
400
+ expect((branchB :: any)._unconsumedException).toEqual(true)
401
+
402
+ branchA:Catch(function() end)
403
+ branchB:Catch(function() end)
404
+ end)
405
+
406
+ it("does not consume an already-rejected source when onRejected is omitted", function()
407
+ local source = Promise.rejected("nope")
408
+ -- Settled + no onRejected returns self, so self stays the carrier and must remain flagged.
409
+ local same = source:Then(function() end)
410
+
411
+ expect(same == source).toEqual(true)
412
+ expect((source :: any)._unconsumedException).toEqual(true)
413
+
414
+ source:Catch(function() end)
415
+ expect((source :: any)._unconsumedException).toEqual(false)
416
+ end)
417
+
418
+ it("runs the tail error handler for the datastore graceful-close shape", function()
419
+ local outer = Promise.new()
420
+ local root = Promise.new()
421
+
422
+ root:Then(function()
423
+ return "unused"
424
+ end)
425
+ :Then(function()
426
+ return "unused"
427
+ end)
428
+ :Then(function(value)
429
+ outer:Resolve(value)
430
+ end, function(err)
431
+ outer:Reject(string.format("locked: %s", err))
432
+ end)
433
+
434
+ root:Reject("still locked")
435
+
436
+ local outcome, err = PromiseTestUtils.awaitOutcome(outer)
437
+ expect(outcome).toEqual("rejected")
438
+ expect(err).toEqual("locked: still locked")
439
+
440
+ expect((root :: any)._unconsumedException).toEqual(false)
441
+ end)
442
+ end)
443
+
327
444
  describe("Promise:Catch", function()
328
445
  it("handles a rejection", function()
329
446
  local outcome, value = PromiseTestUtils.awaitOutcome(Promise.rejected("caught"):Catch(function(err)