@tanstack/pacer 0.21.0 → 0.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.
Files changed (79) hide show
  1. package/README.md +17 -2
  2. package/dist/async-batcher.cjs +1 -1
  3. package/dist/async-batcher.cjs.map +1 -1
  4. package/dist/async-batcher.d.cts +0 -1
  5. package/dist/async-batcher.d.ts +0 -1
  6. package/dist/async-batcher.js +1 -1
  7. package/dist/async-batcher.js.map +1 -1
  8. package/dist/async-debouncer.cjs +2 -5
  9. package/dist/async-debouncer.cjs.map +1 -1
  10. package/dist/async-debouncer.d.cts +5 -6
  11. package/dist/async-debouncer.d.ts +5 -6
  12. package/dist/async-debouncer.js +2 -5
  13. package/dist/async-debouncer.js.map +1 -1
  14. package/dist/async-queuer.cjs +49 -17
  15. package/dist/async-queuer.cjs.map +1 -1
  16. package/dist/async-queuer.d.cts +1 -1
  17. package/dist/async-queuer.d.ts +1 -1
  18. package/dist/async-queuer.js +49 -17
  19. package/dist/async-queuer.js.map +1 -1
  20. package/dist/async-rate-limiter.cjs +3 -6
  21. package/dist/async-rate-limiter.cjs.map +1 -1
  22. package/dist/async-rate-limiter.d.cts +4 -5
  23. package/dist/async-rate-limiter.d.ts +4 -5
  24. package/dist/async-rate-limiter.js +3 -6
  25. package/dist/async-rate-limiter.js.map +1 -1
  26. package/dist/async-retryer.cjs +2 -11
  27. package/dist/async-retryer.cjs.map +1 -1
  28. package/dist/async-retryer.d.cts +4 -2
  29. package/dist/async-retryer.d.ts +4 -2
  30. package/dist/async-retryer.js +2 -11
  31. package/dist/async-retryer.js.map +1 -1
  32. package/dist/async-throttler.cjs +2 -5
  33. package/dist/async-throttler.cjs.map +1 -1
  34. package/dist/async-throttler.d.cts +5 -6
  35. package/dist/async-throttler.d.ts +5 -6
  36. package/dist/async-throttler.js +2 -5
  37. package/dist/async-throttler.js.map +1 -1
  38. package/dist/batcher.cjs.map +1 -1
  39. package/dist/batcher.d.cts +0 -1
  40. package/dist/batcher.d.ts +0 -1
  41. package/dist/batcher.js.map +1 -1
  42. package/dist/debouncer.cjs.map +1 -1
  43. package/dist/debouncer.d.cts +0 -1
  44. package/dist/debouncer.d.ts +0 -1
  45. package/dist/debouncer.js.map +1 -1
  46. package/dist/event-client.cjs +8 -0
  47. package/dist/event-client.cjs.map +1 -1
  48. package/dist/event-client.d.cts +0 -1
  49. package/dist/event-client.d.ts +0 -1
  50. package/dist/event-client.js +8 -0
  51. package/dist/event-client.js.map +1 -1
  52. package/dist/index.cjs +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/queuer.cjs +7 -2
  55. package/dist/queuer.cjs.map +1 -1
  56. package/dist/queuer.d.cts +1 -1
  57. package/dist/queuer.d.ts +1 -1
  58. package/dist/queuer.js +7 -2
  59. package/dist/queuer.js.map +1 -1
  60. package/dist/rate-limiter.cjs.map +1 -1
  61. package/dist/rate-limiter.d.cts +0 -1
  62. package/dist/rate-limiter.d.ts +0 -1
  63. package/dist/rate-limiter.js.map +1 -1
  64. package/dist/throttler.cjs.map +1 -1
  65. package/dist/throttler.d.cts +0 -1
  66. package/dist/throttler.d.ts +0 -1
  67. package/dist/throttler.js.map +1 -1
  68. package/dist/utils.cjs +0 -1
  69. package/dist/utils.cjs.map +1 -1
  70. package/dist/utils.d.cts +0 -1
  71. package/dist/utils.d.ts +0 -1
  72. package/dist/utils.js.map +1 -1
  73. package/package.json +3 -3
  74. package/src/async-debouncer.ts +30 -29
  75. package/src/async-queuer.ts +83 -20
  76. package/src/async-rate-limiter.ts +11 -10
  77. package/src/async-retryer.ts +4 -15
  78. package/src/async-throttler.ts +33 -32
  79. package/src/queuer.ts +13 -2
@@ -431,25 +431,36 @@ export class AsyncQueuer<TValue> {
431
431
  this.#checkExpiredItems()
432
432
 
433
433
  // Process items concurrently up to the concurrency limit
434
- const activeItems = this.store.state.activeItems
434
+ let scheduledAsyncWork = false
435
+ const activeItems = [...this.store.state.activeItems]
435
436
  while (
436
437
  activeItems.length < this.#getConcurrency() &&
437
438
  this.store.state.items.length > 0
438
439
  ) {
439
440
  const nextItem = this.peekNextItem()
440
- if (!nextItem) {
441
+ if (nextItem === undefined) {
441
442
  break
442
443
  }
443
444
  activeItems.push(nextItem)
444
445
  this.#setState({
445
- activeItems,
446
+ activeItems: [...activeItems],
446
447
  })
448
+ scheduledAsyncWork = true
447
449
  ;(async () => {
448
- await this.execute()
450
+ try {
451
+ await this.execute()
452
+ } catch {
453
+ // errors are already surfaced via onError/errorCount (and rethrown to
454
+ // direct execute/flush callers); swallowing here prevents unhandled
455
+ // rejections and keeps the processing chain alive
456
+ }
449
457
 
450
458
  const wait = this.#getWait()
451
459
  if (wait > 0) {
452
- const timeoutId = setTimeout(() => this.#tick(), wait)
460
+ const timeoutId = setTimeout(() => {
461
+ this.#timeoutIds.delete(timeoutId)
462
+ this.#tick()
463
+ }, wait)
453
464
  this.#timeoutIds.add(timeoutId)
454
465
  return
455
466
  }
@@ -458,12 +469,17 @@ export class AsyncQueuer<TValue> {
458
469
  })()
459
470
  }
460
471
 
461
- this.#setState({ pendingTick: false })
472
+ // pendingTick must stay true while executions or wait timers are pending so
473
+ // that addItem does not trigger an extra tick that bypasses the wait period
474
+ if (!scheduledAsyncWork) {
475
+ this.#setState({ pendingTick: false })
476
+ }
462
477
  }
463
478
 
464
479
  /**
465
480
  * Adds an item to the queue. If the queue is full, the item is rejected and onReject is called.
466
481
  * Items can be inserted based on priority or at the front/back depending on configuration.
482
+ * `undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected.
467
483
  *
468
484
  * @example
469
485
  * ```ts
@@ -480,6 +496,16 @@ export class AsyncQueuer<TValue> {
480
496
  addItemCount: this.store.state.addItemCount + 1,
481
497
  })
482
498
 
499
+ // undefined is the internal "no item" sentinel (peekNextItem/getNextItem);
500
+ // queuing it would wedge the processing loop and block items behind it
501
+ if (item === undefined) {
502
+ this.#setState({
503
+ rejectionCount: this.store.state.rejectionCount + 1,
504
+ })
505
+ this.options.onReject?.(item, this)
506
+ return false
507
+ }
508
+
483
509
  if (this.store.state.items.length >= (this.options.maxSize ?? Infinity)) {
484
510
  this.#setState({
485
511
  rejectionCount: this.store.state.rejectionCount + 1,
@@ -492,7 +518,7 @@ export class AsyncQueuer<TValue> {
492
518
  const priority =
493
519
  this.options.getPriority !== defaultOptions.getPriority
494
520
  ? this.options.getPriority!(item)
495
- : (item as any).priority
521
+ : (item as any)?.priority
496
522
 
497
523
  const items = this.store.state.items
498
524
  const itemTimestamps = this.store.state.itemTimestamps
@@ -503,7 +529,7 @@ export class AsyncQueuer<TValue> {
503
529
  const existingPriority =
504
530
  this.options.getPriority !== defaultOptions.getPriority
505
531
  ? this.options.getPriority!(existing)
506
- : (existing as any).priority
532
+ : (existing as any)?.priority
507
533
  return existingPriority < priority
508
534
  })
509
535
 
@@ -616,10 +642,10 @@ export class AsyncQueuer<TValue> {
616
642
  isExecuting: true,
617
643
  })
618
644
  try {
619
- const currentAsyncRetryer = new AsyncRetryer(this.fn, {
620
- ...this.options.asyncRetryerOptions,
621
- key: `${this.key}-retryer-${currentExecuteCount}`,
622
- })
645
+ const currentAsyncRetryer = new AsyncRetryer(
646
+ this.fn,
647
+ this.options.asyncRetryerOptions,
648
+ )
623
649
  this.asyncRetryers.set(currentExecuteCount, currentAsyncRetryer)
624
650
  const lastResult = await currentAsyncRetryer.execute(item) // EXECUTE!
625
651
  this.#setState({
@@ -637,11 +663,17 @@ export class AsyncQueuer<TValue> {
637
663
  }
638
664
  } finally {
639
665
  this.asyncRetryers.delete(currentExecuteCount) // dispose retryer
666
+ // remove only one occurrence so duplicate item values keep accurate
667
+ // concurrency accounting
668
+ const remainingActiveItems = [...this.store.state.activeItems]
669
+ const activeItemIndex = remainingActiveItems.indexOf(item)
670
+ if (activeItemIndex !== -1) {
671
+ remainingActiveItems.splice(activeItemIndex, 1)
672
+ }
640
673
  this.#setState({
641
- activeItems: this.store.state.activeItems.filter(
642
- (activeItem) => activeItem !== item,
643
- ),
644
- isExecuting: false,
674
+ activeItems: remainingActiveItems,
675
+ // other executions may still be in flight (concurrency > 1 or flush)
676
+ isExecuting: this.asyncRetryers.size > 0,
645
677
  settledCount: this.store.state.settledCount + 1,
646
678
  })
647
679
  this.options.onSettled?.(item, this)
@@ -658,10 +690,33 @@ export class AsyncQueuer<TValue> {
658
690
  numberOfItems: number = this.store.state.items.length,
659
691
  position?: QueuePosition,
660
692
  ): Promise<void> => {
661
- this.#clearTimeouts() // clear any pending timeouts
662
- await Promise.all(
693
+ this.#clearTimeouts() // clear any pending timeouts (kills the tick chain)
694
+ const results = await Promise.allSettled(
663
695
  Array.from({ length: numberOfItems }, () => this.execute(position)),
664
696
  )
697
+ // the tick chain was killed above; restart it so remaining and future items
698
+ // process even when a flushed task rejected. Remaining items resume with the
699
+ // normal wait spacing after the flushed executions.
700
+ this.#setState({ pendingTick: false })
701
+ if (this.store.state.isRunning && this.store.state.items.length > 0) {
702
+ const wait = this.#getWait()
703
+ if (wait > 0) {
704
+ this.#setState({ pendingTick: true })
705
+ const timeoutId = setTimeout(() => {
706
+ this.#timeoutIds.delete(timeoutId)
707
+ this.#tick()
708
+ }, wait)
709
+ this.#timeoutIds.add(timeoutId)
710
+ } else {
711
+ this.#tick()
712
+ }
713
+ }
714
+ const failure = results.find(
715
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
716
+ )
717
+ if (failure) {
718
+ throw failure.reason
719
+ }
665
720
  }
666
721
 
667
722
  /**
@@ -671,9 +726,17 @@ export class AsyncQueuer<TValue> {
671
726
  flushAsBatch = async (
672
727
  batchFunction: (items: Array<TValue>) => Promise<any>,
673
728
  ): Promise<void> => {
674
- this.#clearTimeouts() // clear any pending timeouts
729
+ this.#clearTimeouts() // clear any pending timeouts (kills the tick chain)
675
730
  const items = this.#getAllItems()
676
- await batchFunction(items)
731
+ try {
732
+ await batchFunction(items)
733
+ } finally {
734
+ // restore the tick chain even when the batch function rejects
735
+ this.#setState({ pendingTick: false })
736
+ if (this.store.state.isRunning && this.store.state.items.length > 0) {
737
+ this.#tick()
738
+ }
739
+ }
677
740
  }
678
741
 
679
742
  /**
@@ -25,7 +25,7 @@ export interface AsyncRateLimiterState<TFn extends AnyAsyncFunction> {
25
25
  /**
26
26
  * The result from the most recent successful function execution
27
27
  */
28
- lastResult: ReturnType<TFn> | undefined
28
+ lastResult: Awaited<ReturnType<TFn>> | undefined
29
29
  /**
30
30
  * Number of function executions that have been rejected due to rate limiting
31
31
  */
@@ -118,7 +118,7 @@ export interface AsyncRateLimiterOptions<TFn extends AnyAsyncFunction> {
118
118
  * Optional function to call when the rate-limited function is executed
119
119
  */
120
120
  onSuccess?: (
121
- result: ReturnType<TFn>,
121
+ result: Awaited<ReturnType<TFn>>,
122
122
  args: Parameters<TFn>,
123
123
  rateLimiter: AsyncRateLimiter<TFn>,
124
124
  ) => void
@@ -357,7 +357,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
357
357
  */
358
358
  maybeExecute = async (
359
359
  ...args: Parameters<TFn>
360
- ): Promise<ReturnType<TFn> | undefined> => {
360
+ ): Promise<Awaited<ReturnType<TFn>> | undefined> => {
361
361
  this.#setState({
362
362
  maybeExecuteCount: this.store.state.maybeExecuteCount + 1,
363
363
  })
@@ -380,7 +380,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
380
380
 
381
381
  #execute = async (
382
382
  ...args: Parameters<TFn>
383
- ): Promise<ReturnType<TFn> | undefined> => {
383
+ ): Promise<Awaited<ReturnType<TFn>> | undefined> => {
384
384
  if (!this.#getEnabled()) return
385
385
 
386
386
  const currentMaybeExecute = this.store.state.maybeExecuteCount
@@ -393,10 +393,10 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
393
393
 
394
394
  try {
395
395
  // Create a new AsyncRetryer for this execution to avoid cancelling concurrent executions
396
- const currentAsyncRetryer = new AsyncRetryer(this.fn, {
397
- ...this.options.asyncRetryerOptions,
398
- key: `${this.key}-retryer-${currentMaybeExecute}`,
399
- })
396
+ const currentAsyncRetryer = new AsyncRetryer(
397
+ this.fn,
398
+ this.options.asyncRetryerOptions,
399
+ )
400
400
  this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer)
401
401
  const result = await currentAsyncRetryer.execute(...args) // EXECUTE!
402
402
  this.#setCleanupTimeout(now)
@@ -404,7 +404,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
404
404
  successCount: this.store.state.successCount + 1,
405
405
  lastResult: result,
406
406
  })
407
- this.options.onSuccess?.(result as ReturnType<TFn>, args, this)
407
+ this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)
408
408
  } catch (error) {
409
409
  this.#setState({
410
410
  errorCount: this.store.state.errorCount + 1,
@@ -416,7 +416,8 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
416
416
  } finally {
417
417
  this.asyncRetryers.delete(currentMaybeExecute) // dispose retryer
418
418
  this.#setState({
419
- isExecuting: false,
419
+ // other executions may still be in flight within the window
420
+ isExecuting: this.asyncRetryers.size > 0,
420
421
  settleCount: this.store.state.settleCount + 1,
421
422
  })
422
423
  this.options.onSettled?.(args, this)
@@ -1,6 +1,5 @@
1
1
  import { Store } from '@tanstack/store'
2
2
  import { parseFunctionOrValue } from './utils'
3
- import { emitChange, pacerEventClient } from './event-client'
4
3
  import type { AnyAsyncFunction } from './types'
5
4
 
6
5
  export interface AsyncRetryerState<TFn extends AnyAsyncFunction> {
@@ -87,7 +86,10 @@ export interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
87
86
  jitter?: number
88
87
  /**
89
88
  * Optional key to identify this async retryer instance.
90
- * If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable.
89
+ * Note: async retryers are not currently surfaced in the devtools, so this key
90
+ * is only a plain identifier. Retryer instances are often created per-execution
91
+ * (including internally by the other async utilities), so they intentionally do
92
+ * not register with the devtools event bus.
91
93
  */
92
94
  key?: string
93
95
  /**
@@ -319,18 +321,6 @@ export class AsyncRetryer<TFn extends AnyAsyncFunction> {
319
321
  (initialOptions.onError ? false : defaultOptions.throwOnError),
320
322
  }
321
323
  this.#setState(this.options.initialState ?? {})
322
-
323
- if (this.key) {
324
- pacerEventClient.on('d-AsyncRetryer', (event) => {
325
- if (event.payload.key !== this.key) return
326
- this.#setState(
327
- event.payload.store.state as Partial<AsyncRetryerState<TFn>>,
328
- )
329
- this.setOptions(
330
- event.payload.options as Partial<AsyncRetryerOptions<TFn>>,
331
- )
332
- })
333
- }
334
324
  }
335
325
 
336
326
  /**
@@ -359,7 +349,6 @@ export class AsyncRetryer<TFn extends AnyAsyncFunction> {
359
349
  : 'idle',
360
350
  }
361
351
  })
362
- emitChange('AsyncRetryer', this)
363
352
  }
364
353
 
365
354
  #getEnabled = (): boolean => {
@@ -29,7 +29,7 @@ export interface AsyncThrottlerState<TFn extends AnyAsyncFunction> {
29
29
  /**
30
30
  * The result from the most recent successful function execution
31
31
  */
32
- lastResult: ReturnType<TFn> | undefined
32
+ lastResult: Awaited<ReturnType<TFn>> | undefined
33
33
  /**
34
34
  * Number of times maybeExecute has been called (for reduction calculations)
35
35
  */
@@ -119,7 +119,7 @@ export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
119
119
  * Optional function to call when the throttled function is executed
120
120
  */
121
121
  onSuccess?: (
122
- result: ReturnType<TFn>,
122
+ result: Awaited<ReturnType<TFn>>,
123
123
  args: Parameters<TFn>,
124
124
  asyncThrottler: AsyncThrottler<TFn>,
125
125
  ) => void
@@ -236,8 +236,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
236
236
  asyncRetryers = new Map<number, AsyncRetryer<TFn>>()
237
237
  #timeoutId: ReturnType<typeof setTimeout> | null = null
238
238
  #resolvePreviousPromise:
239
- | ((value?: ReturnType<TFn> | undefined) => void)
240
- | null = null
239
+ ((value?: Awaited<ReturnType<TFn>> | undefined) => void) | null = null
241
240
 
242
241
  constructor(
243
242
  public fn: TFn,
@@ -337,7 +336,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
337
336
  */
338
337
  maybeExecute = async (
339
338
  ...args: Parameters<TFn>
340
- ): Promise<ReturnType<TFn> | undefined> => {
339
+ ): Promise<Awaited<ReturnType<TFn>> | undefined> => {
341
340
  if (!this.#getEnabled()) return undefined
342
341
 
343
342
  this.#resolvePreviousPromiseInternal()
@@ -380,51 +379,53 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
380
379
  })
381
380
 
382
381
  // Set up new trailing execution
383
- return new Promise((resolve, reject) => {
384
- this.#resolvePreviousPromise = resolve
385
-
386
- const newTimeSinceLastExecution = this.store.state.lastExecutionTime
387
- ? now - this.store.state.lastExecutionTime
388
- : 0
389
- const timeoutDuration = Math.max(0, wait - newTimeSinceLastExecution)
390
-
391
- this.#timeoutId = setTimeout(async () => {
392
- this.#clearTimeout()
393
- if (this.store.state.lastArgs !== undefined) {
394
- try {
395
- await this.#execute(...this.store.state.lastArgs) // Trailing EXECUTE!
396
- } catch (error) {
397
- reject(error)
382
+ return new Promise<Awaited<ReturnType<TFn>> | undefined>(
383
+ (resolve, reject) => {
384
+ this.#resolvePreviousPromise = resolve
385
+
386
+ const newTimeSinceLastExecution = this.store.state.lastExecutionTime
387
+ ? now - this.store.state.lastExecutionTime
388
+ : 0
389
+ const timeoutDuration = Math.max(0, wait - newTimeSinceLastExecution)
390
+
391
+ this.#timeoutId = setTimeout(async () => {
392
+ this.#clearTimeout()
393
+ if (this.store.state.lastArgs !== undefined) {
394
+ try {
395
+ await this.#execute(...this.store.state.lastArgs) // Trailing EXECUTE!
396
+ } catch (error) {
397
+ reject(error)
398
+ }
398
399
  }
399
- }
400
- this.#resolvePreviousPromise = null
401
- resolve(this.store.state.lastResult)
402
- }, timeoutDuration)
403
- })
400
+ this.#resolvePreviousPromise = null
401
+ resolve(this.store.state.lastResult)
402
+ }, timeoutDuration)
403
+ },
404
+ )
404
405
  }
405
406
  return this.store.state.lastResult
406
407
  }
407
408
 
408
409
  #execute = async (
409
410
  ...args: Parameters<TFn>
410
- ): Promise<ReturnType<TFn> | undefined> => {
411
+ ): Promise<Awaited<ReturnType<TFn>> | undefined> => {
411
412
  if (!this.#getEnabled()) return undefined
412
413
 
413
414
  const currentMaybeExecute = this.store.state.maybeExecuteCount
414
415
 
415
416
  try {
416
417
  this.#setState({ isExecuting: true })
417
- const currentAsyncRetryer = new AsyncRetryer(this.fn, {
418
- ...this.options.asyncRetryerOptions,
419
- key: `${this.key}-retryer-${currentMaybeExecute}`,
420
- })
418
+ const currentAsyncRetryer = new AsyncRetryer(
419
+ this.fn,
420
+ this.options.asyncRetryerOptions,
421
+ )
421
422
  this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer)
422
423
  const result = await currentAsyncRetryer.execute(...args) // EXECUTE!
423
424
  this.#setState({
424
425
  lastResult: result,
425
426
  successCount: this.store.state.successCount + 1,
426
427
  })
427
- this.options.onSuccess?.(result as ReturnType<TFn>, args, this)
428
+ this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)
428
429
  } catch (error) {
429
430
  this.#setState({
430
431
  errorCount: this.store.state.errorCount + 1,
@@ -459,7 +460,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
459
460
  /**
460
461
  * Processes the current pending execution immediately
461
462
  */
462
- flush = async (): Promise<ReturnType<TFn> | undefined> => {
463
+ flush = async (): Promise<Awaited<ReturnType<TFn>> | undefined> => {
463
464
  if (this.store.state.isPending && this.store.state.lastArgs) {
464
465
  // Store the pending promise resolver before clearing timeout
465
466
  const resolvePromise = this.#resolvePreviousPromise
package/src/queuer.ts CHANGED
@@ -390,6 +390,7 @@ export class Queuer<TValue> {
390
390
 
391
391
  /**
392
392
  * Adds an item to the queue. If the queue is full, the item is rejected and onReject is called.
393
+ * `undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected.
393
394
  * Items can be inserted based on priority or at the front/back depending on configuration.
394
395
  *
395
396
  * Returns true if the item was added, false if the queue is full.
@@ -409,6 +410,16 @@ export class Queuer<TValue> {
409
410
  addItemCount: this.store.state.addItemCount + 1,
410
411
  })
411
412
 
413
+ // undefined is the internal "no item" sentinel (peekNextItem/getNextItem);
414
+ // queuing it would break the processing loop and block items behind it
415
+ if (item === undefined) {
416
+ this.#setState({
417
+ rejectionCount: this.store.state.rejectionCount + 1,
418
+ })
419
+ this.options.onReject?.(item, this)
420
+ return false
421
+ }
422
+
412
423
  if (this.store.state.items.length >= (this.options.maxSize ?? Infinity)) {
413
424
  this.#setState({
414
425
  rejectionCount: this.store.state.rejectionCount + 1,
@@ -421,7 +432,7 @@ export class Queuer<TValue> {
421
432
  const priority =
422
433
  this.options.getPriority !== defaultOptions.getPriority
423
434
  ? this.options.getPriority!(item)
424
- : (item as any).priority
435
+ : (item as any)?.priority
425
436
 
426
437
  const items = this.store.state.items
427
438
  const itemTimestamps = this.store.state.itemTimestamps
@@ -432,7 +443,7 @@ export class Queuer<TValue> {
432
443
  const existingPriority: number =
433
444
  this.options.getPriority !== defaultOptions.getPriority
434
445
  ? this.options.getPriority!(existing)
435
- : (existing as any).priority
446
+ : (existing as any)?.priority
436
447
  return existingPriority < priority
437
448
  })
438
449