From 4ab7a8f67a62c9b5683faa614f564243c5e215d5 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Thu, 6 Aug 2026 18:06:36 -0500 Subject: [PATCH 1/4] fix: falsy queue items, AsyncRetryer devtools leak, wait bypass, and Awaited return types Closes #200, #198, #188, #156 Co-Authored-By: Simon Meyer <33751665+simonmeyerrr@users.noreply.github.com> Co-Authored-By: Calum Jarvis <92122419+caluhm@users.noreply.github.com> Co-Authored-By: Vincent Ricard Co-Authored-By: Claude Fable 5 --- .changeset/async-queuer-falsy-items.md | 5 + .changeset/async-queuer-pending-tick.md | 5 + .changeset/awaited-return-types.md | 7 + .changeset/retryer-key-guard.md | 5 + .../functions/useAsyncDebouncedCallback.md | 11 +- .../functions/useAsyncRateLimitedCallback.md | 6 +- .../functions/useAsyncThrottledCallback.md | 11 +- .../functions/useAsyncDebouncedCallback.md | 11 +- .../functions/useAsyncRateLimitedCallback.md | 6 +- .../functions/useAsyncThrottledCallback.md | 11 +- docs/reference/classes/AsyncDebouncer.md | 18 +- docs/reference/classes/AsyncQueuer.md | 30 +-- docs/reference/classes/AsyncRateLimiter.md | 4 +- docs/reference/classes/AsyncRetryer.md | 22 +- docs/reference/classes/AsyncThrottler.md | 18 +- docs/reference/functions/asyncDebounce.md | 8 +- docs/reference/functions/asyncQueue.md | 2 +- docs/reference/functions/asyncRateLimit.md | 6 +- docs/reference/functions/asyncRetry.md | 2 +- .../functions/asyncRetryerOptions.md | 2 +- docs/reference/functions/asyncThrottle.md | 8 +- .../interfaces/AsyncDebouncerOptions.md | 2 +- .../interfaces/AsyncDebouncerState.md | 2 +- .../interfaces/AsyncRateLimiterOptions.md | 2 +- .../interfaces/AsyncRateLimiterState.md | 2 +- .../interfaces/AsyncRetryerOptions.md | 45 ++-- .../reference/interfaces/AsyncRetryerState.md | 18 +- .../interfaces/AsyncThrottlerOptions.md | 2 +- .../interfaces/AsyncThrottlerState.md | 2 +- packages/pacer/src/async-debouncer.ts | 58 ++-- packages/pacer/src/async-queuer.ts | 45 +++- packages/pacer/src/async-rate-limiter.ts | 18 +- packages/pacer/src/async-retryer.ts | 19 +- packages/pacer/src/async-throttler.ts | 64 ++--- packages/pacer/src/queuer.ts | 4 +- packages/pacer/tests/async-debouncer.test.ts | 67 ++++- packages/pacer/tests/async-queuer.test.ts | 248 +++++++++++++++++- .../pacer/tests/async-rate-limiter.test.ts | 65 ++++- packages/pacer/tests/async-retryer.test.ts | 15 ++ packages/pacer/tests/async-throttler.test.ts | 47 +++- packages/pacer/tests/queuer.test.ts | 14 + .../useAsyncDebouncedCallback.ts | 8 +- .../useAsyncRateLimitedCallback.ts | 5 +- .../useAsyncThrottledCallback.ts | 8 +- .../useAsyncDebouncedCallback.ts | 10 +- .../useAsyncRateLimitedCallback.ts | 4 +- .../useAsyncThrottledCallback.ts | 10 +- 47 files changed, 734 insertions(+), 248 deletions(-) create mode 100644 .changeset/async-queuer-falsy-items.md create mode 100644 .changeset/async-queuer-pending-tick.md create mode 100644 .changeset/awaited-return-types.md create mode 100644 .changeset/retryer-key-guard.md diff --git a/.changeset/async-queuer-falsy-items.md b/.changeset/async-queuer-falsy-items.md new file mode 100644 index 000000000..6b6572732 --- /dev/null +++ b/.changeset/async-queuer-falsy-items.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': patch +--- + +fix(queuer): stop silently dropping falsy items (`0`, `''`, `false`) from AsyncQueuer processing, and stop throwing a TypeError when `null` items are added to AsyncQueuer or Queuer without a custom `getPriority` (fixes #200) diff --git a/.changeset/async-queuer-pending-tick.md b/.changeset/async-queuer-pending-tick.md new file mode 100644 index 000000000..03c3e1902 --- /dev/null +++ b/.changeset/async-queuer-pending-tick.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': patch +--- + +fix(async-queuer): respect the `wait` period when `addItem` is called during active processing. `pendingTick` now stays true while executions or wait timers are pending (matching the sync Queuer's semantics), task errors no longer kill the processing chain or produce unhandled promise rejections during tick-driven processing, and `flush`/`flushAsBatch` restart the tick chain they interrupt (fixes #188) diff --git a/.changeset/awaited-return-types.md b/.changeset/awaited-return-types.md new file mode 100644 index 000000000..03c596075 --- /dev/null +++ b/.changeset/awaited-return-types.md @@ -0,0 +1,7 @@ +--- +'@tanstack/pacer': patch +'@tanstack/react-pacer': patch +'@tanstack/preact-pacer': patch +--- + +fix: async utility return types no longer double-wrap promises. `maybeExecute`, `flush`, `lastResult` state, and `onSuccess` callbacks on AsyncDebouncer, AsyncThrottler, and AsyncRateLimiter (and the `asyncDebounce`/`asyncThrottle`/`asyncRateLimit` helpers) now use `Awaited>` instead of `ReturnType`. The `useAsyncDebouncedCallback`, `useAsyncThrottledCallback`, and `useAsyncRateLimitedCallback` hooks in react-pacer and preact-pacer now return `Promise> | undefined>`, matching the angular adapter and the actual runtime behavior (fixes #156) diff --git a/.changeset/retryer-key-guard.md b/.changeset/retryer-key-guard.md new file mode 100644 index 000000000..a91272b64 --- /dev/null +++ b/.changeset/retryer-key-guard.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': patch +--- + +fix: remove the devtools event-client integration from AsyncRetryer to fix unbounded memory growth (#198). Retryer instances are created per-execution by AsyncQueuer, AsyncDebouncer, AsyncThrottler, and AsyncRateLimiter, so every execution permanently accumulated a devtools event listener, a live instance in the devtools registry (keyed `"-retryer-N"`, or `"undefined-retryer-N"` when the parent had no key), and queued devtools events — most visibly as a memory leak in Node.js. The devtools panel never consumed retryer events, so no devtools functionality is lost. The `key` option on AsyncRetryer remains as a plain identifier, and internal retryers now receive `asyncRetryerOptions` unmodified diff --git a/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md b/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md index db52685c8..1646e1a33 100644 --- a/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md +++ b/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md @@ -6,10 +6,10 @@ title: useAsyncDebouncedCallback # Function: useAsyncDebouncedCallback() ```ts -function useAsyncDebouncedCallback(fn, options): (...args) => Promise>; +function useAsyncDebouncedCallback(fn, options): (...args) => Promise> | undefined>; ``` -Defined in: [preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:44](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L44) +Defined in: [preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:45](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L45) A Preact hook that creates a debounced version of an async callback function. This hook is a convenient wrapper around the `useAsyncDebouncer` hook, @@ -18,7 +18,8 @@ providing a stable, debounced async function reference for use in Preact compone The debounced async function will only execute after the specified wait time has elapsed since its last invocation. If called again before the wait time expires, the timer resets and starts waiting again. The returned function always returns a promise -that resolves or rejects with the result of the original async function. +that resolves or rejects with the result of the original async function, or +resolves with `undefined` if the call was superseded or the debouncer is disabled. This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. @@ -50,7 +51,7 @@ Consider using the `useAsyncDebouncer` hook instead. ## Returns ```ts -(...args): Promise>; +(...args): Promise> | undefined>; ``` ### Parameters @@ -61,7 +62,7 @@ Consider using the `useAsyncDebouncer` hook instead. ### Returns -`Promise`\<`ReturnType`\<`TFn`\>\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ## Example diff --git a/docs/framework/preact/reference/functions/useAsyncRateLimitedCallback.md b/docs/framework/preact/reference/functions/useAsyncRateLimitedCallback.md index 9ecfa3431..ee6cc200f 100644 --- a/docs/framework/preact/reference/functions/useAsyncRateLimitedCallback.md +++ b/docs/framework/preact/reference/functions/useAsyncRateLimitedCallback.md @@ -6,7 +6,7 @@ title: useAsyncRateLimitedCallback # Function: useAsyncRateLimitedCallback() ```ts -function useAsyncRateLimitedCallback(fn, options): (...args) => Promise>; +function useAsyncRateLimitedCallback(fn, options): (...args) => Promise> | undefined>; ``` Defined in: [preact-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts:59](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts#L59) @@ -64,7 +64,7 @@ Consider using the `useAsyncRateLimiter` hook instead. ## Returns ```ts -(...args): Promise>; +(...args): Promise> | undefined>; ``` ### Parameters @@ -75,7 +75,7 @@ Consider using the `useAsyncRateLimiter` hook instead. ### Returns -`Promise`\<`ReturnType`\<`TFn`\>\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ## Example diff --git a/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md b/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md index 82aa308a2..e1a358f37 100644 --- a/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md +++ b/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md @@ -6,10 +6,10 @@ title: useAsyncThrottledCallback # Function: useAsyncThrottledCallback() ```ts -function useAsyncThrottledCallback(fn, options): (...args) => Promise>; +function useAsyncThrottledCallback(fn, options): (...args) => Promise> | undefined>; ``` -Defined in: [preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts:42](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts#L42) +Defined in: [preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts:43](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts#L43) A Preact hook that creates a throttled version of an async callback function. This hook is a convenient wrapper around the `useAsyncThrottler` hook, @@ -19,7 +19,8 @@ The throttled async function will execute at most once within the specified wait regardless of how many times it is called. If called multiple times during the wait period, only the first invocation will execute, and subsequent calls will be ignored until the wait period has elapsed. The returned function always returns a promise -that resolves or rejects with the result of the original async function. +that resolves or rejects with the result of the original async function, or +resolves with `undefined` if the call was throttled or the throttler is disabled. This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic async throttling needs. However, it does not expose the underlying AsyncThrottler instance. @@ -51,7 +52,7 @@ Consider using the `useAsyncThrottler` hook instead. ## Returns ```ts -(...args): Promise>; +(...args): Promise> | undefined>; ``` ### Parameters @@ -62,7 +63,7 @@ Consider using the `useAsyncThrottler` hook instead. ### Returns -`Promise`\<`ReturnType`\<`TFn`\>\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ## Example diff --git a/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md b/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md index df82e2607..d09725dc6 100644 --- a/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md +++ b/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md @@ -6,10 +6,10 @@ title: useAsyncDebouncedCallback # Function: useAsyncDebouncedCallback() ```ts -function useAsyncDebouncedCallback(fn, options): (...args) => Promise>; +function useAsyncDebouncedCallback(fn, options): (...args) => Promise> | undefined>; ``` -Defined in: [react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:44](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L44) +Defined in: [react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:45](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L45) A React hook that creates a debounced version of an async callback function. This hook is a convenient wrapper around the `useAsyncDebouncer` hook, @@ -18,7 +18,8 @@ providing a stable, debounced async function reference for use in React componen The debounced async function will only execute after the specified wait time has elapsed since its last invocation. If called again before the wait time expires, the timer resets and starts waiting again. The returned function always returns a promise -that resolves or rejects with the result of the original async function. +that resolves or rejects with the result of the original async function, or +resolves with `undefined` if the call was superseded or the debouncer is disabled. This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. @@ -50,7 +51,7 @@ Consider using the `useAsyncDebouncer` hook instead. ## Returns ```ts -(...args): Promise>; +(...args): Promise> | undefined>; ``` ### Parameters @@ -61,7 +62,7 @@ Consider using the `useAsyncDebouncer` hook instead. ### Returns -`Promise`\<`ReturnType`\<`TFn`\>\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ## Example diff --git a/docs/framework/react/reference/functions/useAsyncRateLimitedCallback.md b/docs/framework/react/reference/functions/useAsyncRateLimitedCallback.md index 23bd706a8..fedafa6f8 100644 --- a/docs/framework/react/reference/functions/useAsyncRateLimitedCallback.md +++ b/docs/framework/react/reference/functions/useAsyncRateLimitedCallback.md @@ -6,7 +6,7 @@ title: useAsyncRateLimitedCallback # Function: useAsyncRateLimitedCallback() ```ts -function useAsyncRateLimitedCallback(fn, options): (...args) => Promise>; +function useAsyncRateLimitedCallback(fn, options): (...args) => Promise> | undefined>; ``` Defined in: [react-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts:59](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts#L59) @@ -64,7 +64,7 @@ Consider using the `useAsyncRateLimiter` hook instead. ## Returns ```ts -(...args): Promise>; +(...args): Promise> | undefined>; ``` ### Parameters @@ -75,7 +75,7 @@ Consider using the `useAsyncRateLimiter` hook instead. ### Returns -`Promise`\<`ReturnType`\<`TFn`\>\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ## Example diff --git a/docs/framework/react/reference/functions/useAsyncThrottledCallback.md b/docs/framework/react/reference/functions/useAsyncThrottledCallback.md index f8303ee8e..c57a68875 100644 --- a/docs/framework/react/reference/functions/useAsyncThrottledCallback.md +++ b/docs/framework/react/reference/functions/useAsyncThrottledCallback.md @@ -6,10 +6,10 @@ title: useAsyncThrottledCallback # Function: useAsyncThrottledCallback() ```ts -function useAsyncThrottledCallback(fn, options): (...args) => Promise>; +function useAsyncThrottledCallback(fn, options): (...args) => Promise> | undefined>; ``` -Defined in: [react-pacer/src/async-throttler/useAsyncThrottledCallback.ts:42](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts#L42) +Defined in: [react-pacer/src/async-throttler/useAsyncThrottledCallback.ts:43](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts#L43) A React hook that creates a throttled version of an async callback function. This hook is a convenient wrapper around the `useAsyncThrottler` hook, @@ -19,7 +19,8 @@ The throttled async function will execute at most once within the specified wait regardless of how many times it is called. If called multiple times during the wait period, only the first invocation will execute, and subsequent calls will be ignored until the wait period has elapsed. The returned function always returns a promise -that resolves or rejects with the result of the original async function. +that resolves or rejects with the result of the original async function, or +resolves with `undefined` if the call was throttled or the throttler is disabled. This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic async throttling needs. However, it does not expose the underlying AsyncThrottler instance. @@ -51,7 +52,7 @@ Consider using the `useAsyncThrottler` hook instead. ## Returns ```ts -(...args): Promise>; +(...args): Promise> | undefined>; ``` ### Parameters @@ -62,7 +63,7 @@ Consider using the `useAsyncThrottler` hook instead. ### Returns -`Promise`\<`ReturnType`\<`TFn`\>\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ## Example diff --git a/docs/reference/classes/AsyncDebouncer.md b/docs/reference/classes/AsyncDebouncer.md index a113f89b7..9335a447c 100644 --- a/docs/reference/classes/AsyncDebouncer.md +++ b/docs/reference/classes/AsyncDebouncer.md @@ -149,7 +149,7 @@ Defined in: [async-debouncer.ts:219](https://github.com/TanStack/pacer/blob/main abort(): void; ``` -Defined in: [async-debouncer.ts:468](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L468) +Defined in: [async-debouncer.ts:470](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L470) Aborts all ongoing executions with the internal abort controllers. Does NOT cancel any pending execution that have not started yet. @@ -166,7 +166,7 @@ Does NOT cancel any pending execution that have not started yet. cancel(): void; ``` -Defined in: [async-debouncer.ts:480](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L480) +Defined in: [async-debouncer.ts:482](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L482) Cancels any pending execution that have not started yet. Does NOT abort any execution already in progress. @@ -180,16 +180,16 @@ Does NOT abort any execution already in progress. ### flush() ```ts -flush(): Promise | undefined>; +flush(): Promise> | undefined>; ``` -Defined in: [async-debouncer.ts:403](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L403) +Defined in: [async-debouncer.ts:405](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L405) Processes the current pending execution immediately #### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> *** @@ -199,7 +199,7 @@ Processes the current pending execution immediately getAbortSignal(maybeExecuteCount?): AbortSignal | null; ``` -Defined in: [async-debouncer.ts:458](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L458) +Defined in: [async-debouncer.ts:460](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L460) Returns the AbortSignal for a specific execution. If no maybeExecuteCount is provided, returns the signal for the most recent execution. @@ -237,7 +237,7 @@ const debouncer = new AsyncDebouncer( ### maybeExecute() ```ts -maybeExecute(...args): Promise | undefined>; +maybeExecute(...args): Promise> | undefined>; ``` Defined in: [async-debouncer.ts:317](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L317) @@ -260,7 +260,7 @@ Error Handling: #### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError @@ -276,7 +276,7 @@ The error from the debounced function if no onError handler is configured reset(): void; ``` -Defined in: [async-debouncer.ts:488](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L488) +Defined in: [async-debouncer.ts:490](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L490) Resets the debouncer state to its default values diff --git a/docs/reference/classes/AsyncQueuer.md b/docs/reference/classes/AsyncQueuer.md index f09c0ee28..f8c440f11 100644 --- a/docs/reference/classes/AsyncQueuer.md +++ b/docs/reference/classes/AsyncQueuer.md @@ -168,7 +168,7 @@ Defined in: [async-queuer.ts:316](https://github.com/TanStack/pacer/blob/main/pa abort(): void; ``` -Defined in: [async-queuer.ts:836](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L836) +Defined in: [async-queuer.ts:857](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L857) Aborts all ongoing executions with the internal abort controllers. Does NOT clear out the items. @@ -188,7 +188,7 @@ addItem( runOnItemsChange): boolean; ``` -Defined in: [async-queuer.ts:474](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L474) +Defined in: [async-queuer.ts:489](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L489) Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. Items can be inserted based on priority or at the front/back depending on configuration. @@ -226,7 +226,7 @@ queuer.addItem('task2', 'front'); clear(): void; ``` -Defined in: [async-queuer.ts:801](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L801) +Defined in: [async-queuer.ts:822](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L822) Removes all pending items from the queue. Does NOT affect active tasks. @@ -243,7 +243,7 @@ Does NOT affect active tasks. execute(position?): Promise; ``` -Defined in: [async-queuer.ts:609](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L609) +Defined in: [async-queuer.ts:624](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L624) Removes and returns the next item from the queue and executes the task function with it. @@ -273,7 +273,7 @@ queuer.execute('back'); flush(numberOfItems, position?): Promise; ``` -Defined in: [async-queuer.ts:657](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L657) +Defined in: [async-queuer.ts:672](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L672) Processes a specified number of items to execute immediately with no wait time If no numberOfItems is provided, all items will be processed @@ -300,7 +300,7 @@ If no numberOfItems is provided, all items will be processed flushAsBatch(batchFunction): Promise; ``` -Defined in: [async-queuer.ts:671](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L671) +Defined in: [async-queuer.ts:691](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L691) Processes all items in the queue as a batch using the provided function as an argument The queue is cleared after processing @@ -323,7 +323,7 @@ The queue is cleared after processing getAbortSignal(executeCount?): AbortSignal | null; ``` -Defined in: [async-queuer.ts:826](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L826) +Defined in: [async-queuer.ts:847](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L847) Returns the AbortSignal for a specific execution. If no executeCount is provided, returns the signal for the most recent execution. @@ -364,7 +364,7 @@ const queuer = new AsyncQueuer( getNextItem(position): TValue | undefined; ``` -Defined in: [async-queuer.ts:557](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L557) +Defined in: [async-queuer.ts:572](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L572) Removes and returns the next item from the queue without executing the task function. Use for manual queue management. Normally, use execute() to process items. @@ -396,7 +396,7 @@ queuer.getNextItem('back'); peekActiveItems(): TValue[]; ``` -Defined in: [async-queuer.ts:763](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L763) +Defined in: [async-queuer.ts:784](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L784) Returns the items currently being processed (active tasks). @@ -412,7 +412,7 @@ Returns the items currently being processed (active tasks). peekAllItems(): TValue[]; ``` -Defined in: [async-queuer.ts:756](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L756) +Defined in: [async-queuer.ts:777](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L777) Returns a copy of all items in the queue, including active and pending items. @@ -428,7 +428,7 @@ Returns a copy of all items in the queue, including active and pending items. peekNextItem(position): TValue | undefined; ``` -Defined in: [async-queuer.ts:746](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L746) +Defined in: [async-queuer.ts:767](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L767) Returns the next item in the queue without removing it. @@ -457,7 +457,7 @@ queuer.peekNextItem('back'); // back peekPendingItems(): TValue[]; ``` -Defined in: [async-queuer.ts:770](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L770) +Defined in: [async-queuer.ts:791](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L791) Returns the items waiting to be processed (pending tasks). @@ -473,7 +473,7 @@ Returns the items waiting to be processed (pending tasks). reset(): void; ``` -Defined in: [async-queuer.ts:847](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L847) +Defined in: [async-queuer.ts:868](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L868) Resets the queuer state to its default values @@ -511,7 +511,7 @@ Updates the queuer options. New options are merged with existing options. start(): void; ``` -Defined in: [async-queuer.ts:777](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L777) +Defined in: [async-queuer.ts:798](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L798) Starts processing items in the queue. If already running, does nothing. @@ -527,7 +527,7 @@ Starts processing items in the queue. If already running, does nothing. stop(): void; ``` -Defined in: [async-queuer.ts:787](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L787) +Defined in: [async-queuer.ts:808](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L808) Stops processing items in the queue. Does not clear the queue. diff --git a/docs/reference/classes/AsyncRateLimiter.md b/docs/reference/classes/AsyncRateLimiter.md index 91a917687..33ae88880 100644 --- a/docs/reference/classes/AsyncRateLimiter.md +++ b/docs/reference/classes/AsyncRateLimiter.md @@ -258,7 +258,7 @@ Returns the number of remaining executions allowed in the current window ### maybeExecute() ```ts -maybeExecute(...args): Promise | undefined>; +maybeExecute(...args): Promise> | undefined>; ``` Defined in: [async-rate-limiter.ts:358](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L358) @@ -281,7 +281,7 @@ Error Handling: #### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError diff --git a/docs/reference/classes/AsyncRetryer.md b/docs/reference/classes/AsyncRetryer.md index b168648be..aa83391f3 100644 --- a/docs/reference/classes/AsyncRetryer.md +++ b/docs/reference/classes/AsyncRetryer.md @@ -5,7 +5,7 @@ title: AsyncRetryer # Class: AsyncRetryer\ -Defined in: [async-retryer.ts:296](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L296) +Defined in: [async-retryer.ts:298](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L298) Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies, attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably @@ -115,7 +115,7 @@ The async function type to be retried. new AsyncRetryer(fn, initialOptions): AsyncRetryer; ``` -Defined in: [async-retryer.ts:309](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L309) +Defined in: [async-retryer.ts:311](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L311) Creates a new AsyncRetryer instance @@ -145,7 +145,7 @@ Configuration options for the retryer fn: TFn; ``` -Defined in: [async-retryer.ts:310](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L310) +Defined in: [async-retryer.ts:312](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L312) The async function to retry @@ -157,7 +157,7 @@ The async function to retry key: string | undefined; ``` -Defined in: [async-retryer.ts:300](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L300) +Defined in: [async-retryer.ts:302](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L302) *** @@ -177,7 +177,7 @@ options: AsyncRetryerOptions & Omit>, | "onTotalExecutionTimeout">; ``` -Defined in: [async-retryer.ts:301](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L301) +Defined in: [async-retryer.ts:303](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L303) *** @@ -187,7 +187,7 @@ Defined in: [async-retryer.ts:301](https://github.com/TanStack/pacer/blob/main/p readonly store: Store>>; ``` -Defined in: [async-retryer.ts:297](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L297) +Defined in: [async-retryer.ts:299](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L299) ## Methods @@ -197,7 +197,7 @@ Defined in: [async-retryer.ts:297](https://github.com/TanStack/pacer/blob/main/p abort(reason): void; ``` -Defined in: [async-retryer.ts:623](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L623) +Defined in: [async-retryer.ts:612](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L612) Cancels the current execution and any pending retries @@ -221,7 +221,7 @@ The reason for the abort (defaults to 'manual') execute(...args): Promise> | undefined>; ``` -Defined in: [async-retryer.ts:430](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L430) +Defined in: [async-retryer.ts:419](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L419) Executes the function with retry logic @@ -251,7 +251,7 @@ The last error if throwOnError is true and all retries fail getAbortSignal(): AbortSignal | null; ``` -Defined in: [async-retryer.ts:615](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L615) +Defined in: [async-retryer.ts:604](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L604) Returns the current AbortSignal for the executing operation. Use this signal in your async function to make it cancellable. @@ -284,7 +284,7 @@ retryer.abort() reset(): void; ``` -Defined in: [async-retryer.ts:643](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L643) +Defined in: [async-retryer.ts:632](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L632) Resets the retryer to its initial state @@ -300,7 +300,7 @@ Resets the retryer to its initial state setOptions(newOptions): void; ``` -Defined in: [async-retryer.ts:340](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L340) +Defined in: [async-retryer.ts:330](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L330) Updates the retryer options diff --git a/docs/reference/classes/AsyncThrottler.md b/docs/reference/classes/AsyncThrottler.md index 240ebd2ab..bf6d98217 100644 --- a/docs/reference/classes/AsyncThrottler.md +++ b/docs/reference/classes/AsyncThrottler.md @@ -153,7 +153,7 @@ Defined in: [async-throttler.ts:231](https://github.com/TanStack/pacer/blob/main abort(): void; ``` -Defined in: [async-throttler.ts:532](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L532) +Defined in: [async-throttler.ts:534](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L534) Aborts all ongoing executions with the internal abort controllers. Does NOT cancel any pending execution that have not started yet. @@ -170,7 +170,7 @@ Does NOT cancel any pending execution that have not started yet. cancel(): void; ``` -Defined in: [async-throttler.ts:542](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L542) +Defined in: [async-throttler.ts:544](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L544) Cancels any pending execution that have not started yet. Does NOT abort any execution already in progress. @@ -184,16 +184,16 @@ Does NOT abort any execution already in progress. ### flush() ```ts -flush(): Promise | undefined>; +flush(): Promise> | undefined>; ``` -Defined in: [async-throttler.ts:461](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L461) +Defined in: [async-throttler.ts:463](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L463) Processes the current pending execution immediately #### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> *** @@ -203,7 +203,7 @@ Processes the current pending execution immediately getAbortSignal(maybeExecuteCount?): AbortSignal | null; ``` -Defined in: [async-throttler.ts:522](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L522) +Defined in: [async-throttler.ts:524](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L524) Returns the AbortSignal for a specific execution. If no maybeExecuteCount is provided, returns the signal for the most recent execution. @@ -245,7 +245,7 @@ const throttler = new AsyncThrottler( ### maybeExecute() ```ts -maybeExecute(...args): Promise | undefined>; +maybeExecute(...args): Promise> | undefined>; ``` Defined in: [async-throttler.ts:337](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L337) @@ -268,7 +268,7 @@ Attempts to execute the throttled function. The execution behavior depends on th #### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> #### Example @@ -290,7 +290,7 @@ await throttled.maybeExecute('c', 'd'); reset(): void; ``` -Defined in: [async-throttler.ts:556](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L556) +Defined in: [async-throttler.ts:558](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L558) Resets the debouncer state to its default values diff --git a/docs/reference/functions/asyncDebounce.md b/docs/reference/functions/asyncDebounce.md index 21e7622d4..a00ba18b5 100644 --- a/docs/reference/functions/asyncDebounce.md +++ b/docs/reference/functions/asyncDebounce.md @@ -6,10 +6,10 @@ title: asyncDebounce # Function: asyncDebounce() ```ts -function asyncDebounce(fn, initialOptions): (...args) => Promise | undefined>; +function asyncDebounce(fn, initialOptions): (...args) => Promise> | undefined>; ``` -Defined in: [async-debouncer.ts:558](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L558) +Defined in: [async-debouncer.ts:560](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L560) Creates an async debounced function that delays execution until after a specified wait time. The debounced function will only execute once the wait period has elapsed without any new calls. @@ -75,7 +75,7 @@ State Management: ## Returns ```ts -(...args): Promise | undefined>; +(...args): Promise> | undefined>; ``` Attempts to execute the debounced function. @@ -96,7 +96,7 @@ Error Handling: ### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError diff --git a/docs/reference/functions/asyncQueue.md b/docs/reference/functions/asyncQueue.md index 0c6d3d276..50c5479d3 100644 --- a/docs/reference/functions/asyncQueue.md +++ b/docs/reference/functions/asyncQueue.md @@ -9,7 +9,7 @@ title: asyncQueue function asyncQueue(fn, initialOptions): (item, position, runOnItemsChange) => boolean; ``` -Defined in: [async-queuer.ts:919](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L919) +Defined in: [async-queuer.ts:940](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L940) Creates a new AsyncQueuer instance and returns a bound addItem function for adding tasks. The queuer is started automatically and ready to process items. diff --git a/docs/reference/functions/asyncRateLimit.md b/docs/reference/functions/asyncRateLimit.md index e835e3502..f3cd083b2 100644 --- a/docs/reference/functions/asyncRateLimit.md +++ b/docs/reference/functions/asyncRateLimit.md @@ -6,7 +6,7 @@ title: asyncRateLimit # Function: asyncRateLimit() ```ts -function asyncRateLimit(fn, initialOptions): (...args) => Promise | undefined>; +function asyncRateLimit(fn, initialOptions): (...args) => Promise> | undefined>; ``` Defined in: [async-rate-limiter.ts:641](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L641) @@ -89,7 +89,7 @@ State Management: ## Returns ```ts -(...args): Promise | undefined>; +(...args): Promise> | undefined>; ``` Attempts to execute the rate-limited function if within the configured limits. @@ -110,7 +110,7 @@ Error Handling: ### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> A promise that resolves with the function's return value, or undefined if an error occurred and was handled by onError diff --git a/docs/reference/functions/asyncRetry.md b/docs/reference/functions/asyncRetry.md index 8e62b55f3..e22043361 100644 --- a/docs/reference/functions/asyncRetry.md +++ b/docs/reference/functions/asyncRetry.md @@ -9,7 +9,7 @@ title: asyncRetry function asyncRetry(fn, initialOptions): (...args) => Promise> | undefined>; ``` -Defined in: [async-retryer.ts:678](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L678) +Defined in: [async-retryer.ts:667](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L667) Creates a retry-enabled version of an async function. This is a convenience wrapper around the AsyncRetryer class that returns the execute method. diff --git a/docs/reference/functions/asyncRetryerOptions.md b/docs/reference/functions/asyncRetryerOptions.md index b9b602649..23b6a76ce 100644 --- a/docs/reference/functions/asyncRetryerOptions.md +++ b/docs/reference/functions/asyncRetryerOptions.md @@ -9,7 +9,7 @@ title: asyncRetryerOptions function asyncRetryerOptions(options): TOptions; ``` -Defined in: [async-retryer.ts:169](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L169) +Defined in: [async-retryer.ts:171](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L171) Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances. diff --git a/docs/reference/functions/asyncThrottle.md b/docs/reference/functions/asyncThrottle.md index 6bf6ea486..ca2162d53 100644 --- a/docs/reference/functions/asyncThrottle.md +++ b/docs/reference/functions/asyncThrottle.md @@ -6,10 +6,10 @@ title: asyncThrottle # Function: asyncThrottle() ```ts -function asyncThrottle(fn, initialOptions): (...args) => Promise | undefined>; +function asyncThrottle(fn, initialOptions): (...args) => Promise> | undefined>; ``` -Defined in: [async-throttler.ts:626](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L626) +Defined in: [async-throttler.ts:628](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L628) Creates an async throttled function that limits how often the function can execute. The throttled function will execute at most once per wait period, even if called multiple times. @@ -76,7 +76,7 @@ State Management: ## Returns ```ts -(...args): Promise | undefined>; +(...args): Promise> | undefined>; ``` Attempts to execute the throttled function. The execution behavior depends on the throttler options: @@ -97,7 +97,7 @@ Attempts to execute the throttled function. The execution behavior depends on th ### Returns -`Promise`\<`ReturnType`\<`TFn`\> \| `undefined`\> +`Promise`\<`Awaited`\<`ReturnType`\<`TFn`\>\> \| `undefined`\> ### Example diff --git a/docs/reference/interfaces/AsyncDebouncerOptions.md b/docs/reference/interfaces/AsyncDebouncerOptions.md index d41642fc3..6c9541dd6 100644 --- a/docs/reference/interfaces/AsyncDebouncerOptions.md +++ b/docs/reference/interfaces/AsyncDebouncerOptions.md @@ -153,7 +153,7 @@ Optional callback to call when the debounced function is executed ##### result -`ReturnType`\<`TFn`\> +`Awaited`\<`ReturnType`\<`TFn`\>\> ##### args diff --git a/docs/reference/interfaces/AsyncDebouncerState.md b/docs/reference/interfaces/AsyncDebouncerState.md index dfd6874bf..506aa73e0 100644 --- a/docs/reference/interfaces/AsyncDebouncerState.md +++ b/docs/reference/interfaces/AsyncDebouncerState.md @@ -78,7 +78,7 @@ The arguments from the most recent call to maybeExecute ### lastResult ```ts -lastResult: ReturnType | undefined; +lastResult: Awaited> | undefined; ``` Defined in: [async-debouncer.ts:32](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-debouncer.ts#L32) diff --git a/docs/reference/interfaces/AsyncRateLimiterOptions.md b/docs/reference/interfaces/AsyncRateLimiterOptions.md index 6be480c16..993bc64a5 100644 --- a/docs/reference/interfaces/AsyncRateLimiterOptions.md +++ b/docs/reference/interfaces/AsyncRateLimiterOptions.md @@ -179,7 +179,7 @@ Optional function to call when the rate-limited function is executed ##### result -`ReturnType`\<`TFn`\> +`Awaited`\<`ReturnType`\<`TFn`\>\> ##### args diff --git a/docs/reference/interfaces/AsyncRateLimiterState.md b/docs/reference/interfaces/AsyncRateLimiterState.md index b39838235..feeaec93e 100644 --- a/docs/reference/interfaces/AsyncRateLimiterState.md +++ b/docs/reference/interfaces/AsyncRateLimiterState.md @@ -66,7 +66,7 @@ Whether the rate-limited function is currently executing asynchronously ### lastResult ```ts -lastResult: ReturnType | undefined; +lastResult: Awaited> | undefined; ``` Defined in: [async-rate-limiter.ts:28](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L28) diff --git a/docs/reference/interfaces/AsyncRetryerOptions.md b/docs/reference/interfaces/AsyncRetryerOptions.md index e89aad1cc..41f6d952b 100644 --- a/docs/reference/interfaces/AsyncRetryerOptions.md +++ b/docs/reference/interfaces/AsyncRetryerOptions.md @@ -5,7 +5,7 @@ title: AsyncRetryerOptions # Interface: AsyncRetryerOptions\ -Defined in: [async-retryer.ts:60](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L60) +Defined in: [async-retryer.ts:59](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L59) ## Type Parameters @@ -21,7 +21,7 @@ Defined in: [async-retryer.ts:60](https://github.com/TanStack/pacer/blob/main/pa optional backoff: "linear" | "exponential" | "fixed"; ``` -Defined in: [async-retryer.ts:68](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L68) +Defined in: [async-retryer.ts:67](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L67) The backoff strategy for retry delays: - 'exponential': Wait time doubles with each attempt (1s, 2s, 4s, ...) @@ -42,7 +42,7 @@ The backoff strategy for retry delays: optional baseWait: number | (retryer) => number; ``` -Defined in: [async-retryer.ts:73](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L73) +Defined in: [async-retryer.ts:72](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L72) Base wait time in milliseconds between retries, or a function that returns the wait time @@ -60,7 +60,7 @@ Base wait time in milliseconds between retries, or a function that returns the w optional enabled: boolean | (retryer) => boolean; ``` -Defined in: [async-retryer.ts:78](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L78) +Defined in: [async-retryer.ts:77](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L77) Whether the retryer is enabled, or a function that determines if it's enabled @@ -78,7 +78,7 @@ true optional initialState: Partial>; ``` -Defined in: [async-retryer.ts:82](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L82) +Defined in: [async-retryer.ts:81](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L81) Initial state to merge with the default state @@ -90,7 +90,7 @@ Initial state to merge with the default state optional jitter: number; ``` -Defined in: [async-retryer.ts:87](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L87) +Defined in: [async-retryer.ts:86](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L86) Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thundering herd. @@ -108,10 +108,13 @@ Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thund optional key: string; ``` -Defined in: [async-retryer.ts:92](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L92) +Defined in: [async-retryer.ts:94](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L94) Optional key to identify this async retryer instance. -If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable. +Note: async retryers are not currently surfaced in the devtools, so this key +is only a plain identifier. Retryer instances are often created per-execution +(including internally by the other async utilities), so they intentionally do +not register with the devtools event bus. *** @@ -121,7 +124,7 @@ If provided, the async retryer will be identified by this key in the devtools an optional maxAttempts: number | (retryer) => number; ``` -Defined in: [async-retryer.ts:97](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L97) +Defined in: [async-retryer.ts:99](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L99) Maximum number of retry attempts, or a function that returns the max attempts @@ -139,7 +142,7 @@ Maximum number of retry attempts, or a function that returns the max attempts optional maxExecutionTime: number; ``` -Defined in: [async-retryer.ts:102](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L102) +Defined in: [async-retryer.ts:104](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L104) Maximum execution time in milliseconds for a single function call before aborting @@ -157,7 +160,7 @@ Infinity optional maxTotalExecutionTime: number; ``` -Defined in: [async-retryer.ts:107](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L107) +Defined in: [async-retryer.ts:109](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L109) Maximum total execution time in milliseconds for the entire retry operation before aborting @@ -175,7 +178,7 @@ Infinity optional maxWait: number | (retryer) => number; ``` -Defined in: [async-retryer.ts:112](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L112) +Defined in: [async-retryer.ts:114](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L114) Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time @@ -193,7 +196,7 @@ Infinity optional onAbort: (reason, retryer) => void; ``` -Defined in: [async-retryer.ts:116](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L116) +Defined in: [async-retryer.ts:118](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L118) Callback invoked when the execution is aborted (manually or due to timeouts) @@ -219,7 +222,7 @@ Callback invoked when the execution is aborted (manually or due to timeouts) optional onError: (error, args, retryer) => void; ``` -Defined in: [async-retryer.ts:123](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L123) +Defined in: [async-retryer.ts:125](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L125) Callback invoked when any error occurs during execution (including retries) @@ -249,7 +252,7 @@ Callback invoked when any error occurs during execution (including retries) optional onExecutionTimeout: (retryer) => void; ``` -Defined in: [async-retryer.ts:131](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L131) +Defined in: [async-retryer.ts:133](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L133) Callback invoked when a single execution attempt times out (maxExecutionTime exceeded) @@ -271,7 +274,7 @@ Callback invoked when a single execution attempt times out (maxExecutionTime exc optional onLastError: (error, retryer) => void; ``` -Defined in: [async-retryer.ts:135](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L135) +Defined in: [async-retryer.ts:137](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L137) Callback invoked when the final error occurs after all retries are exhausted @@ -297,7 +300,7 @@ Callback invoked when the final error occurs after all retries are exhausted optional onRetry: (attempt, error, retryer) => void; ``` -Defined in: [async-retryer.ts:139](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L139) +Defined in: [async-retryer.ts:141](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L141) Callback invoked before each retry attempt @@ -327,7 +330,7 @@ Callback invoked before each retry attempt optional onSettled: (args, retryer) => void; ``` -Defined in: [async-retryer.ts:143](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L143) +Defined in: [async-retryer.ts:145](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L145) Callback invoked after execution completes (success or failure) of each attempt @@ -353,7 +356,7 @@ Callback invoked after execution completes (success or failure) of each attempt optional onSuccess: (result, args, retryer) => void; ``` -Defined in: [async-retryer.ts:147](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L147) +Defined in: [async-retryer.ts:149](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L149) Callback invoked when execution succeeds @@ -383,7 +386,7 @@ Callback invoked when execution succeeds optional onTotalExecutionTimeout: (retryer) => void; ``` -Defined in: [async-retryer.ts:155](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L155) +Defined in: [async-retryer.ts:157](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L157) Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded) @@ -405,7 +408,7 @@ Callback invoked when the total execution time times out (maxTotalExecutionTime optional throwOnError: boolean | "last"; ``` -Defined in: [async-retryer.ts:163](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L163) +Defined in: [async-retryer.ts:165](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L165) Controls when errors are thrown: - 'last': Only throw the final error after all retries are exhausted diff --git a/docs/reference/interfaces/AsyncRetryerState.md b/docs/reference/interfaces/AsyncRetryerState.md index 75d501a60..c308323fb 100644 --- a/docs/reference/interfaces/AsyncRetryerState.md +++ b/docs/reference/interfaces/AsyncRetryerState.md @@ -5,7 +5,7 @@ title: AsyncRetryerState # Interface: AsyncRetryerState\ -Defined in: [async-retryer.ts:6](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L6) +Defined in: [async-retryer.ts:5](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L5) ## Type Parameters @@ -21,7 +21,7 @@ Defined in: [async-retryer.ts:6](https://github.com/TanStack/pacer/blob/main/pac currentAttempt: number; ``` -Defined in: [async-retryer.ts:10](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L10) +Defined in: [async-retryer.ts:9](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L9) The current retry attempt number (0 when not executing) @@ -33,7 +33,7 @@ The current retry attempt number (0 when not executing) executionCount: number; ``` -Defined in: [async-retryer.ts:14](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L14) +Defined in: [async-retryer.ts:13](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L13) Total number of completed executions (successful or failed) @@ -45,7 +45,7 @@ Total number of completed executions (successful or failed) isExecuting: boolean; ``` -Defined in: [async-retryer.ts:18](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L18) +Defined in: [async-retryer.ts:17](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L17) Whether the retryer is currently executing the function @@ -57,7 +57,7 @@ Whether the retryer is currently executing the function lastError: Error | undefined; ``` -Defined in: [async-retryer.ts:22](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L22) +Defined in: [async-retryer.ts:21](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L21) The most recent error encountered during execution @@ -69,7 +69,7 @@ The most recent error encountered during execution lastExecutionTime: number; ``` -Defined in: [async-retryer.ts:26](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L26) +Defined in: [async-retryer.ts:25](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L25) Timestamp of the last execution completion in milliseconds @@ -81,7 +81,7 @@ Timestamp of the last execution completion in milliseconds lastResult: Awaited> | undefined; ``` -Defined in: [async-retryer.ts:30](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L30) +Defined in: [async-retryer.ts:29](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L29) The result from the most recent successful execution @@ -93,7 +93,7 @@ The result from the most recent successful execution status: "disabled" | "idle" | "executing" | "retrying"; ``` -Defined in: [async-retryer.ts:34](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L34) +Defined in: [async-retryer.ts:33](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L33) Current execution status - 'disabled' when not enabled, 'idle' when ready, 'executing' when running @@ -105,6 +105,6 @@ Current execution status - 'disabled' when not enabled, 'idle' when ready, 'exec totalExecutionTime: number; ``` -Defined in: [async-retryer.ts:38](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L38) +Defined in: [async-retryer.ts:37](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L37) Total time spent executing (including retries) in milliseconds diff --git a/docs/reference/interfaces/AsyncThrottlerOptions.md b/docs/reference/interfaces/AsyncThrottlerOptions.md index 89efc9974..1b51152c8 100644 --- a/docs/reference/interfaces/AsyncThrottlerOptions.md +++ b/docs/reference/interfaces/AsyncThrottlerOptions.md @@ -153,7 +153,7 @@ Optional function to call when the throttled function is executed ##### result -`ReturnType`\<`TFn`\> +`Awaited`\<`ReturnType`\<`TFn`\>\> ##### args diff --git a/docs/reference/interfaces/AsyncThrottlerState.md b/docs/reference/interfaces/AsyncThrottlerState.md index 951d97e63..dcb8eb25c 100644 --- a/docs/reference/interfaces/AsyncThrottlerState.md +++ b/docs/reference/interfaces/AsyncThrottlerState.md @@ -78,7 +78,7 @@ Timestamp of the last function execution in milliseconds ### lastResult ```ts -lastResult: ReturnType | undefined; +lastResult: Awaited> | undefined; ``` Defined in: [async-throttler.ts:32](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-throttler.ts#L32) diff --git a/packages/pacer/src/async-debouncer.ts b/packages/pacer/src/async-debouncer.ts index 33d6cd2b8..5a6a0b657 100644 --- a/packages/pacer/src/async-debouncer.ts +++ b/packages/pacer/src/async-debouncer.ts @@ -29,7 +29,7 @@ export interface AsyncDebouncerState { /** * The result from the most recent successful function execution */ - lastResult: ReturnType | undefined + lastResult: Awaited> | undefined /** * Number of times maybeExecute has been called (for reduction calculations) */ @@ -111,7 +111,7 @@ export interface AsyncDebouncerOptions { * Optional callback to call when the debounced function is executed */ onSuccess?: ( - result: ReturnType, + result: Awaited>, args: Parameters, debouncer: AsyncDebouncer, ) => void @@ -224,7 +224,7 @@ export class AsyncDebouncer { asyncRetryers = new Map>() #timeoutId: ReturnType | null = null #resolvePreviousPromise: - ((value?: ReturnType | undefined) => void) | null = null + ((value?: Awaited> | undefined) => void) | null = null constructor( public fn: TFn, @@ -316,7 +316,7 @@ export class AsyncDebouncer { */ maybeExecute = async ( ...args: Parameters - ): Promise | undefined> => { + ): Promise> | undefined> => { if (!this.#getEnabled()) return undefined this.#cancelPendingExecution() this.#setState({ @@ -336,46 +336,48 @@ export class AsyncDebouncer { this.#setState({ isPending: true }) } - return new Promise((resolve, reject) => { - this.#resolvePreviousPromise = resolve - // this.#rejectPreviousPromise = reject - this.#timeoutId = setTimeout(async () => { - // Execute trailing if enabled - if (this.options.trailing && this.store.state.lastArgs) { - try { - await this.#execute(...this.store.state.lastArgs) - } catch (error) { - reject(error) + return new Promise> | undefined>( + (resolve, reject) => { + this.#resolvePreviousPromise = resolve + // this.#rejectPreviousPromise = reject + this.#timeoutId = setTimeout(async () => { + // Execute trailing if enabled + if (this.options.trailing && this.store.state.lastArgs) { + try { + await this.#execute(...this.store.state.lastArgs) + } catch (error) { + reject(error) + } } - } - // Reset state and resolve - this.#setState({ canLeadingExecute: true }) - this.#resolvePreviousPromise = null - resolve(this.store.state.lastResult) - }, this.#getWait()) - }) + // Reset state and resolve + this.#setState({ canLeadingExecute: true }) + this.#resolvePreviousPromise = null + resolve(this.store.state.lastResult) + }, this.#getWait()) + }, + ) } #execute = async ( ...args: Parameters - ): Promise | undefined> => { + ): Promise> | undefined> => { if (!this.#getEnabled()) return undefined const currentMaybeExecuteCount = this.store.state.maybeExecuteCount + 1 try { this.#setState({ isExecuting: true }) - const currentAsyncRetryer = new AsyncRetryer(this.fn, { - ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentMaybeExecuteCount}`, - }) + const currentAsyncRetryer = new AsyncRetryer( + this.fn, + this.options.asyncRetryerOptions, + ) this.asyncRetryers.set(currentMaybeExecuteCount, currentAsyncRetryer) const result = await currentAsyncRetryer.execute(...args) // EXECUTE! this.#setState({ lastResult: result, successCount: this.store.state.successCount + 1, }) - this.options.onSuccess?.(result as ReturnType, args, this) + this.options.onSuccess?.(result as Awaited>, args, this) } catch (error) { this.#setState({ errorCount: this.store.state.errorCount + 1, @@ -400,7 +402,7 @@ export class AsyncDebouncer { /** * Processes the current pending execution immediately */ - flush = async (): Promise | undefined> => { + flush = async (): Promise> | undefined> => { if (this.store.state.isPending && this.store.state.lastArgs) { const { lastArgs } = this.store.state this.#cancelPendingExecution() diff --git a/packages/pacer/src/async-queuer.ts b/packages/pacer/src/async-queuer.ts index 22925a170..02ac47e8b 100644 --- a/packages/pacer/src/async-queuer.ts +++ b/packages/pacer/src/async-queuer.ts @@ -431,25 +431,36 @@ export class AsyncQueuer { this.#checkExpiredItems() // Process items concurrently up to the concurrency limit + let scheduledAsyncWork = false const activeItems = this.store.state.activeItems while ( activeItems.length < this.#getConcurrency() && this.store.state.items.length > 0 ) { const nextItem = this.peekNextItem() - if (!nextItem) { + if (nextItem === undefined) { break } activeItems.push(nextItem) this.#setState({ activeItems, }) + scheduledAsyncWork = true ;(async () => { - await this.execute() + try { + await this.execute() + } catch { + // errors are already surfaced via onError/errorCount (and rethrown to + // direct execute/flush callers); swallowing here prevents unhandled + // rejections and keeps the processing chain alive + } const wait = this.#getWait() if (wait > 0) { - const timeoutId = setTimeout(() => this.#tick(), wait) + const timeoutId = setTimeout(() => { + this.#timeoutIds.delete(timeoutId) + this.#tick() + }, wait) this.#timeoutIds.add(timeoutId) return } @@ -458,7 +469,11 @@ export class AsyncQueuer { })() } - this.#setState({ pendingTick: false }) + // pendingTick must stay true while executions or wait timers are pending so + // that addItem does not trigger an extra tick that bypasses the wait period + if (!scheduledAsyncWork) { + this.#setState({ pendingTick: false }) + } } /** @@ -492,7 +507,7 @@ export class AsyncQueuer { const priority = this.options.getPriority !== defaultOptions.getPriority ? this.options.getPriority!(item) - : (item as any).priority + : (item as any)?.priority const items = this.store.state.items const itemTimestamps = this.store.state.itemTimestamps @@ -503,7 +518,7 @@ export class AsyncQueuer { const existingPriority = this.options.getPriority !== defaultOptions.getPriority ? this.options.getPriority!(existing) - : (existing as any).priority + : (existing as any)?.priority return existingPriority < priority }) @@ -616,10 +631,10 @@ export class AsyncQueuer { isExecuting: true, }) try { - const currentAsyncRetryer = new AsyncRetryer(this.fn, { - ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentExecuteCount}`, - }) + const currentAsyncRetryer = new AsyncRetryer( + this.fn, + this.options.asyncRetryerOptions, + ) this.asyncRetryers.set(currentExecuteCount, currentAsyncRetryer) const lastResult = await currentAsyncRetryer.execute(item) // EXECUTE! this.#setState({ @@ -658,10 +673,15 @@ export class AsyncQueuer { numberOfItems: number = this.store.state.items.length, position?: QueuePosition, ): Promise => { - this.#clearTimeouts() // clear any pending timeouts + this.#clearTimeouts() // clear any pending timeouts (kills the tick chain) await Promise.all( Array.from({ length: numberOfItems }, () => this.execute(position)), ) + // the tick chain was killed above; restart it so remaining and future items process + this.#setState({ pendingTick: false }) + if (this.store.state.isRunning && this.store.state.items.length > 0) { + this.#tick() + } } /** @@ -671,9 +691,10 @@ export class AsyncQueuer { flushAsBatch = async ( batchFunction: (items: Array) => Promise, ): Promise => { - this.#clearTimeouts() // clear any pending timeouts + this.#clearTimeouts() // clear any pending timeouts (kills the tick chain) const items = this.#getAllItems() await batchFunction(items) + this.#setState({ pendingTick: false }) } /** diff --git a/packages/pacer/src/async-rate-limiter.ts b/packages/pacer/src/async-rate-limiter.ts index 5ac9be42d..7c8564131 100644 --- a/packages/pacer/src/async-rate-limiter.ts +++ b/packages/pacer/src/async-rate-limiter.ts @@ -25,7 +25,7 @@ export interface AsyncRateLimiterState { /** * The result from the most recent successful function execution */ - lastResult: ReturnType | undefined + lastResult: Awaited> | undefined /** * Number of function executions that have been rejected due to rate limiting */ @@ -118,7 +118,7 @@ export interface AsyncRateLimiterOptions { * Optional function to call when the rate-limited function is executed */ onSuccess?: ( - result: ReturnType, + result: Awaited>, args: Parameters, rateLimiter: AsyncRateLimiter, ) => void @@ -357,7 +357,7 @@ export class AsyncRateLimiter { */ maybeExecute = async ( ...args: Parameters - ): Promise | undefined> => { + ): Promise> | undefined> => { this.#setState({ maybeExecuteCount: this.store.state.maybeExecuteCount + 1, }) @@ -380,7 +380,7 @@ export class AsyncRateLimiter { #execute = async ( ...args: Parameters - ): Promise | undefined> => { + ): Promise> | undefined> => { if (!this.#getEnabled()) return const currentMaybeExecute = this.store.state.maybeExecuteCount @@ -393,10 +393,10 @@ export class AsyncRateLimiter { try { // Create a new AsyncRetryer for this execution to avoid cancelling concurrent executions - const currentAsyncRetryer = new AsyncRetryer(this.fn, { - ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentMaybeExecute}`, - }) + const currentAsyncRetryer = new AsyncRetryer( + this.fn, + this.options.asyncRetryerOptions, + ) this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer) const result = await currentAsyncRetryer.execute(...args) // EXECUTE! this.#setCleanupTimeout(now) @@ -404,7 +404,7 @@ export class AsyncRateLimiter { successCount: this.store.state.successCount + 1, lastResult: result, }) - this.options.onSuccess?.(result as ReturnType, args, this) + this.options.onSuccess?.(result as Awaited>, args, this) } catch (error) { this.#setState({ errorCount: this.store.state.errorCount + 1, diff --git a/packages/pacer/src/async-retryer.ts b/packages/pacer/src/async-retryer.ts index 4bbce86d2..9fb101759 100644 --- a/packages/pacer/src/async-retryer.ts +++ b/packages/pacer/src/async-retryer.ts @@ -1,6 +1,5 @@ import { Store } from '@tanstack/store' import { parseFunctionOrValue } from './utils' -import { emitChange, pacerEventClient } from './event-client' import type { AnyAsyncFunction } from './types' export interface AsyncRetryerState { @@ -87,7 +86,10 @@ export interface AsyncRetryerOptions { jitter?: number /** * Optional key to identify this async retryer instance. - * If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable. + * Note: async retryers are not currently surfaced in the devtools, so this key + * is only a plain identifier. Retryer instances are often created per-execution + * (including internally by the other async utilities), so they intentionally do + * not register with the devtools event bus. */ key?: string /** @@ -319,18 +321,6 @@ export class AsyncRetryer { (initialOptions.onError ? false : defaultOptions.throwOnError), } this.#setState(this.options.initialState ?? {}) - - if (this.key) { - pacerEventClient.on('d-AsyncRetryer', (event) => { - if (event.payload.key !== this.key) return - this.#setState( - event.payload.store.state as Partial>, - ) - this.setOptions( - event.payload.options as Partial>, - ) - }) - } } /** @@ -359,7 +349,6 @@ export class AsyncRetryer { : 'idle', } }) - emitChange('AsyncRetryer', this) } #getEnabled = (): boolean => { diff --git a/packages/pacer/src/async-throttler.ts b/packages/pacer/src/async-throttler.ts index 70c1d934d..2f212a00a 100644 --- a/packages/pacer/src/async-throttler.ts +++ b/packages/pacer/src/async-throttler.ts @@ -29,7 +29,7 @@ export interface AsyncThrottlerState { /** * The result from the most recent successful function execution */ - lastResult: ReturnType | undefined + lastResult: Awaited> | undefined /** * Number of times maybeExecute has been called (for reduction calculations) */ @@ -119,7 +119,7 @@ export interface AsyncThrottlerOptions { * Optional function to call when the throttled function is executed */ onSuccess?: ( - result: ReturnType, + result: Awaited>, args: Parameters, asyncThrottler: AsyncThrottler, ) => void @@ -236,7 +236,7 @@ export class AsyncThrottler { asyncRetryers = new Map>() #timeoutId: ReturnType | null = null #resolvePreviousPromise: - ((value?: ReturnType | undefined) => void) | null = null + ((value?: Awaited> | undefined) => void) | null = null constructor( public fn: TFn, @@ -336,7 +336,7 @@ export class AsyncThrottler { */ maybeExecute = async ( ...args: Parameters - ): Promise | undefined> => { + ): Promise> | undefined> => { if (!this.#getEnabled()) return undefined this.#resolvePreviousPromiseInternal() @@ -379,51 +379,53 @@ export class AsyncThrottler { }) // Set up new trailing execution - return new Promise((resolve, reject) => { - this.#resolvePreviousPromise = resolve - - const newTimeSinceLastExecution = this.store.state.lastExecutionTime - ? now - this.store.state.lastExecutionTime - : 0 - const timeoutDuration = Math.max(0, wait - newTimeSinceLastExecution) - - this.#timeoutId = setTimeout(async () => { - this.#clearTimeout() - if (this.store.state.lastArgs !== undefined) { - try { - await this.#execute(...this.store.state.lastArgs) // Trailing EXECUTE! - } catch (error) { - reject(error) + return new Promise> | undefined>( + (resolve, reject) => { + this.#resolvePreviousPromise = resolve + + const newTimeSinceLastExecution = this.store.state.lastExecutionTime + ? now - this.store.state.lastExecutionTime + : 0 + const timeoutDuration = Math.max(0, wait - newTimeSinceLastExecution) + + this.#timeoutId = setTimeout(async () => { + this.#clearTimeout() + if (this.store.state.lastArgs !== undefined) { + try { + await this.#execute(...this.store.state.lastArgs) // Trailing EXECUTE! + } catch (error) { + reject(error) + } } - } - this.#resolvePreviousPromise = null - resolve(this.store.state.lastResult) - }, timeoutDuration) - }) + this.#resolvePreviousPromise = null + resolve(this.store.state.lastResult) + }, timeoutDuration) + }, + ) } return this.store.state.lastResult } #execute = async ( ...args: Parameters - ): Promise | undefined> => { + ): Promise> | undefined> => { if (!this.#getEnabled()) return undefined const currentMaybeExecute = this.store.state.maybeExecuteCount try { this.#setState({ isExecuting: true }) - const currentAsyncRetryer = new AsyncRetryer(this.fn, { - ...this.options.asyncRetryerOptions, - key: `${this.key}-retryer-${currentMaybeExecute}`, - }) + const currentAsyncRetryer = new AsyncRetryer( + this.fn, + this.options.asyncRetryerOptions, + ) this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer) const result = await currentAsyncRetryer.execute(...args) // EXECUTE! this.#setState({ lastResult: result, successCount: this.store.state.successCount + 1, }) - this.options.onSuccess?.(result as ReturnType, args, this) + this.options.onSuccess?.(result as Awaited>, args, this) } catch (error) { this.#setState({ errorCount: this.store.state.errorCount + 1, @@ -458,7 +460,7 @@ export class AsyncThrottler { /** * Processes the current pending execution immediately */ - flush = async (): Promise | undefined> => { + flush = async (): Promise> | undefined> => { if (this.store.state.isPending && this.store.state.lastArgs) { // Store the pending promise resolver before clearing timeout const resolvePromise = this.#resolvePreviousPromise diff --git a/packages/pacer/src/queuer.ts b/packages/pacer/src/queuer.ts index f93969478..799bca51e 100644 --- a/packages/pacer/src/queuer.ts +++ b/packages/pacer/src/queuer.ts @@ -421,7 +421,7 @@ export class Queuer { const priority = this.options.getPriority !== defaultOptions.getPriority ? this.options.getPriority!(item) - : (item as any).priority + : (item as any)?.priority const items = this.store.state.items const itemTimestamps = this.store.state.itemTimestamps @@ -432,7 +432,7 @@ export class Queuer { const existingPriority: number = this.options.getPriority !== defaultOptions.getPriority ? this.options.getPriority!(existing) - : (existing as any).priority + : (existing as any)?.priority return existingPriority < priority }) diff --git a/packages/pacer/tests/async-debouncer.test.ts b/packages/pacer/tests/async-debouncer.test.ts index 66ac0ca99..393d6158c 100644 --- a/packages/pacer/tests/async-debouncer.test.ts +++ b/packages/pacer/tests/async-debouncer.test.ts @@ -1,5 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from 'vitest' import { AsyncDebouncer, asyncDebounce } from '../src/async-debouncer' +import { getPacerDevtoolsInstance } from '../src' describe('AsyncDebouncer', () => { beforeEach(() => { @@ -1360,3 +1369,59 @@ describe('asyncDebounce helper function', () => { }) }) }) + +describe('AsyncDebouncer internal retryer devtools registration', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('should not register internal retryers when the debouncer has no key', async () => { + const debouncer = new AsyncDebouncer(async (value: string) => value, { + wait: 100, + }) + + debouncer.maybeExecute('test') + await vi.advanceTimersByTimeAsync(100) + + expect(getPacerDevtoolsInstance('undefined-retryer-1')).toBeUndefined() + expect(getPacerDevtoolsInstance('undefined-retryer-2')).toBeUndefined() + }) + + it('should not register per-execution internal retryers when the debouncer has a key', async () => { + const debouncer = new AsyncDebouncer(async (value: string) => value, { + wait: 100, + key: 'my-debouncer', + }) + + debouncer.maybeExecute('test') + await vi.advanceTimersByTimeAsync(100) + + expect(getPacerDevtoolsInstance('my-debouncer-retryer-2')).toBeUndefined() + expect(getPacerDevtoolsInstance('my-debouncer')).toBeDefined() // the debouncer itself still registers + }) +}) + +describe('AsyncDebouncer return type inference', () => { + it('should resolve to the awaited return type, not a nested promise', () => { + const debouncer = new AsyncDebouncer(async (value: string) => value, { + wait: 100, + }) + expectTypeOf(debouncer.maybeExecute).returns.toEqualTypeOf< + Promise + >() + expectTypeOf(debouncer.flush).returns.toEqualTypeOf< + Promise + >() + expectTypeOf(debouncer.store.state.lastResult).toEqualTypeOf< + string | undefined + >() + + const debounced = asyncDebounce(async (value: string) => value, { + wait: 100, + }) + expectTypeOf(debounced).returns.toEqualTypeOf>() + }) +}) diff --git a/packages/pacer/tests/async-queuer.test.ts b/packages/pacer/tests/async-queuer.test.ts index 5497797bc..3bdcb1468 100644 --- a/packages/pacer/tests/async-queuer.test.ts +++ b/packages/pacer/tests/async-queuer.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AsyncQueuer } from '../src' +import { AsyncQueuer, getPacerDevtoolsInstance } from '../src' describe('AsyncQueuer', () => { beforeEach(() => { @@ -1095,4 +1095,250 @@ describe('AsyncQueuer', () => { expect(asyncQueuer.getAbortSignal()).toBeNull() }) }) + + describe('falsy and nullish item handling', () => { + it('should process items with falsy values (0, "", false)', async () => { + const processed: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + processed.push(item) + return item + }, + { started: false }, + ) + + asyncQueuer.addItem(0) + asyncQueuer.addItem('') + asyncQueuer.addItem(false) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(processed).toEqual([0, '', false]) + expect(asyncQueuer.store.state.successCount).toBe(3) + }) + + it('should accept and process null items without throwing', async () => { + const processed: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + processed.push(item) + return item + }, + { started: false }, + ) + + expect(() => asyncQueuer.addItem(null)).not.toThrow() + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(processed).toEqual([null]) + expect(asyncQueuer.store.state.successCount).toBe(1) + }) + + it('should interleave falsy items with truthy items in order', async () => { + const processed: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + processed.push(item) + return item + }, + { started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem(0) + asyncQueuer.addItem('b') + asyncQueuer.addItem(null) + asyncQueuer.addItem(false) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(processed).toEqual(['a', 0, 'b', null, false]) + }) + }) + + describe('wait period semantics', () => { + it('should respect wait when addItem is called during the wait window', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { wait: 100, concurrency: 1, started: false }, + ) + + asyncQueuer.addItem('first') + asyncQueuer.start() + + // 'first' processes immediately + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['first']) + + // during the 100ms wait window, add another item + await vi.advanceTimersByTimeAsync(50) + asyncQueuer.addItem('second') + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['first']) // must NOT run until the wait elapses + + await vi.advanceTimersByTimeAsync(50) + expect(results).toEqual(['first', 'second']) + }) + + it('should respect wait with concurrency > 1 and mid-wait addItem', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { wait: 100, concurrency: 2, started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['a', 'b']) // both start immediately (concurrency 2) + + asyncQueuer.addItem('c') + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['a', 'b']) // c waits out the wait window + + await vi.advanceTimersByTimeAsync(100) + expect(results).toEqual(['a', 'b', 'c']) + }) + + it('should continue processing queued items after a task error (throwOnError default)', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + if (item === 'bad') throw new Error('boom') + results.push(item) + return item + }, + { started: false }, // no onError → throwOnError defaults to true + ) + + asyncQueuer.addItem('bad') + asyncQueuer.addItem('good') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(results).toEqual(['good']) + expect(asyncQueuer.store.state.errorCount).toBe(1) + }) + + it('should continue the wait chain after a task error when wait > 0', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + if (item === 'bad') throw new Error('boom') + results.push(item) + return item + }, + { wait: 100, started: false }, + ) + + asyncQueuer.addItem('bad') + asyncQueuer.addItem('good') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual([]) + expect(asyncQueuer.store.state.errorCount).toBe(1) + + await vi.advanceTimersByTimeAsync(99) + expect(results).toEqual([]) // still inside the wait window + + await vi.advanceTimersByTimeAsync(1) + expect(results).toEqual(['good']) + }) + + it('should continue processing new items added after flush() during a wait window', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { wait: 100, started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) // 'a' processed, wait timer pending + await asyncQueuer.flush() // flush 'b' immediately, killing the wait timer + expect(results).toEqual(['a', 'b']) + + asyncQueuer.addItem('c') + await vi.advanceTimersByTimeAsync(200) + expect(results).toEqual(['a', 'b', 'c']) + }) + + it('should resume correctly after stop then start during a wait window', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { wait: 100, started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['a']) + + asyncQueuer.stop() // clears the wait timer + await vi.advanceTimersByTimeAsync(500) + expect(results).toEqual(['a']) + + asyncQueuer.start() + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['a', 'b']) + }) + }) + + describe('internal retryer devtools registration', () => { + it('should not register internal retryers when the queuer has no key', async () => { + const asyncQueuer = new AsyncQueuer(async (item) => item * 2, { + started: false, + }) + + asyncQueuer.addItem(1) + asyncQueuer.addItem(2) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(getPacerDevtoolsInstance('undefined-retryer-1')).toBeUndefined() + expect(getPacerDevtoolsInstance('undefined-retryer-2')).toBeUndefined() + }) + + it('should not register per-execution internal retryers when the queuer has a key', async () => { + const asyncQueuer = new AsyncQueuer(async (item) => item * 2, { + started: false, + key: 'my-queuer', + }) + + asyncQueuer.addItem(1) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(getPacerDevtoolsInstance('my-queuer-retryer-1')).toBeUndefined() + expect(getPacerDevtoolsInstance('my-queuer')).toBeDefined() // the queuer itself still registers + }) + }) }) diff --git a/packages/pacer/tests/async-rate-limiter.test.ts b/packages/pacer/tests/async-rate-limiter.test.ts index 22bcae3c8..17d4b8c27 100644 --- a/packages/pacer/tests/async-rate-limiter.test.ts +++ b/packages/pacer/tests/async-rate-limiter.test.ts @@ -1,5 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from 'vitest' import { AsyncRateLimiter, asyncRateLimit } from '../src/async-rate-limiter' +import { getPacerDevtoolsInstance } from '../src' describe('AsyncRateLimiter', () => { beforeEach(() => { @@ -776,3 +785,57 @@ describe('asyncRateLimit', () => { }) }) }) + +describe('AsyncRateLimiter internal retryer devtools registration', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('should not register internal retryers with or without a key', async () => { + const rateLimiter = new AsyncRateLimiter(async (value: string) => value, { + limit: 5, + window: 1000, + }) + + await rateLimiter.maybeExecute('test') + + expect(getPacerDevtoolsInstance('undefined-retryer-1')).toBeUndefined() + + const keyedRateLimiter = new AsyncRateLimiter( + async (value: string) => value, + { limit: 5, window: 1000, key: 'my-rate-limiter' }, + ) + + await keyedRateLimiter.maybeExecute('test') + + expect( + getPacerDevtoolsInstance('my-rate-limiter-retryer-1'), + ).toBeUndefined() + }) +}) + +describe('AsyncRateLimiter return type inference', () => { + it('should resolve to the awaited return type, not a nested promise', () => { + const rateLimiter = new AsyncRateLimiter(async (value: string) => value, { + limit: 5, + window: 1000, + }) + expectTypeOf(rateLimiter.maybeExecute).returns.toEqualTypeOf< + Promise + >() + expectTypeOf(rateLimiter.store.state.lastResult).toEqualTypeOf< + string | undefined + >() + + const rateLimited = asyncRateLimit(async (value: string) => value, { + limit: 5, + window: 1000, + }) + expectTypeOf(rateLimited).returns.toEqualTypeOf< + Promise + >() + }) +}) diff --git a/packages/pacer/tests/async-retryer.test.ts b/packages/pacer/tests/async-retryer.test.ts index 448ea9194..bef54ab71 100644 --- a/packages/pacer/tests/async-retryer.test.ts +++ b/packages/pacer/tests/async-retryer.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncRetryer, asyncRetry } from '../src/async-retryer' +import { getPacerDevtoolsInstance } from '../src' describe('AsyncRetryer', () => { beforeEach(() => { @@ -1143,3 +1144,17 @@ describe('asyncRetry utility function', () => { }) }) }) + +describe('AsyncRetryer devtools registration', () => { + it('should not register with the devtools registry even when keyed', async () => { + const retryer = new AsyncRetryer(async (value: string) => value, { + key: 'my-retryer', + }) + + expect(retryer.key).toBe('my-retryer') + + await retryer.execute('test') + + expect(getPacerDevtoolsInstance('my-retryer')).toBeUndefined() + }) +}) diff --git a/packages/pacer/tests/async-throttler.test.ts b/packages/pacer/tests/async-throttler.test.ts index 2cb1072ef..1cba6dad6 100644 --- a/packages/pacer/tests/async-throttler.test.ts +++ b/packages/pacer/tests/async-throttler.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { AsyncThrottler } from '../src/async-throttler' +import { getPacerDevtoolsInstance } from '../src' describe('AsyncThrottler', () => { beforeEach(() => { @@ -999,3 +1000,47 @@ describe('AsyncThrottler', () => { }) }) }) + +describe('AsyncThrottler internal retryer devtools registration', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('should not register internal retryers with or without a key', async () => { + const throttler = new AsyncThrottler(async (value: string) => value, { + wait: 100, + }) + + throttler.maybeExecute('test') + await vi.advanceTimersByTimeAsync(100) + + expect(getPacerDevtoolsInstance('undefined-retryer-1')).toBeUndefined() + + const keyedThrottler = new AsyncThrottler(async (value: string) => value, { + wait: 100, + key: 'my-throttler', + }) + + keyedThrottler.maybeExecute('test') + await vi.advanceTimersByTimeAsync(100) + + expect(getPacerDevtoolsInstance('my-throttler-retryer-1')).toBeUndefined() + }) +}) + +describe('AsyncThrottler return type inference', () => { + it('should resolve to the awaited return type, not a nested promise', () => { + const throttler = new AsyncThrottler(async (value: string) => value, { + wait: 100, + }) + expectTypeOf(throttler.maybeExecute).returns.toEqualTypeOf< + Promise + >() + expectTypeOf(throttler.flush).returns.toEqualTypeOf< + Promise + >() + expectTypeOf(throttler.store.state.lastResult).toEqualTypeOf< + string | undefined + >() + }) +}) diff --git a/packages/pacer/tests/queuer.test.ts b/packages/pacer/tests/queuer.test.ts index e65daef5b..9e25e2bc6 100644 --- a/packages/pacer/tests/queuer.test.ts +++ b/packages/pacer/tests/queuer.test.ts @@ -535,4 +535,18 @@ describe('Queuer', () => { }) }) }) + + describe('nullish item handling', () => { + it('should accept and process null items without throwing', () => { + const processed: Array = [] + const queuer = new Queuer((item) => processed.push(item), { + started: false, + }) + + expect(() => queuer.addItem(null)).not.toThrow() + queuer.start() + + expect(processed).toEqual([null]) + }) + }) }) diff --git a/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts b/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts index 8234ee077..c3e279864 100644 --- a/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts +++ b/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts @@ -11,7 +11,8 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * The debounced async function will only execute after the specified wait time has elapsed * since its last invocation. If called again before the wait time expires, the timer * resets and starts waiting again. The returned function always returns a promise - * that resolves or rejects with the result of the original async function. + * that resolves or rejects with the result of the original async function, or + * resolves with `undefined` if the call was superseded or the debouncer is disabled. * * This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic * async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. @@ -44,11 +45,10 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' export function useAsyncDebouncedCallback( fn: TFn, options: PreactAsyncDebouncerOptions, -): (...args: Parameters) => Promise> { +): (...args: Parameters) => Promise> | undefined> { const asyncDebouncedFn = useAsyncDebouncer(fn, options).maybeExecute return useCallback( - (...args: Parameters) => - asyncDebouncedFn(...args) as Promise>, + (...args: Parameters) => asyncDebouncedFn(...args), [asyncDebouncedFn], ) } diff --git a/packages/preact-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts b/packages/preact-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts index 052d0d5f7..b98a3407c 100644 --- a/packages/preact-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts +++ b/packages/preact-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts @@ -59,11 +59,10 @@ import type { PreactAsyncRateLimiterOptions } from './useAsyncRateLimiter' export function useAsyncRateLimitedCallback( fn: TFn, options: PreactAsyncRateLimiterOptions, -): (...args: Parameters) => Promise> { +): (...args: Parameters) => Promise> | undefined> { const asyncRateLimitedFn = useAsyncRateLimiter(fn, options).maybeExecute return useCallback( - (...args: Parameters) => - asyncRateLimitedFn(...args) as Promise>, + (...args: Parameters) => asyncRateLimitedFn(...args), [asyncRateLimitedFn], ) } diff --git a/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts b/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts index 896d17793..571004234 100644 --- a/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts +++ b/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts @@ -12,7 +12,8 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * regardless of how many times it is called. If called multiple times during the wait period, * only the first invocation will execute, and subsequent calls will be ignored until * the wait period has elapsed. The returned function always returns a promise - * that resolves or rejects with the result of the original async function. + * that resolves or rejects with the result of the original async function, or + * resolves with `undefined` if the call was throttled or the throttler is disabled. * * This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic * async throttling needs. However, it does not expose the underlying AsyncThrottler instance. @@ -42,11 +43,10 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' export function useAsyncThrottledCallback( fn: TFn, options: PreactAsyncThrottlerOptions, -): (...args: Parameters) => Promise> { +): (...args: Parameters) => Promise> | undefined> { const asyncThrottledFn = useAsyncThrottler(fn, options).maybeExecute return useCallback( - (...args: Parameters) => - asyncThrottledFn(...args) as Promise>, + (...args: Parameters) => asyncThrottledFn(...args), [asyncThrottledFn], ) } diff --git a/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts b/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts index d919357a7..1742c57ab 100644 --- a/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts +++ b/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts @@ -11,7 +11,8 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * The debounced async function will only execute after the specified wait time has elapsed * since its last invocation. If called again before the wait time expires, the timer * resets and starts waiting again. The returned function always returns a promise - * that resolves or rejects with the result of the original async function. + * that resolves or rejects with the result of the original async function, or + * resolves with `undefined` if the call was superseded or the debouncer is disabled. * * This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic * async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. @@ -44,10 +45,7 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' export function useAsyncDebouncedCallback( fn: TFn, options: ReactAsyncDebouncerOptions, -): (...args: Parameters) => Promise> { +): (...args: Parameters) => Promise> | undefined> { const asyncDebouncedFn = useAsyncDebouncer(fn, options).maybeExecute - return useCallback( - (...args) => asyncDebouncedFn(...args) as Promise>, - [asyncDebouncedFn], - ) + return useCallback((...args) => asyncDebouncedFn(...args), [asyncDebouncedFn]) } diff --git a/packages/react-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts b/packages/react-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts index 49c8d794f..8c95ef6a1 100644 --- a/packages/react-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts +++ b/packages/react-pacer/src/async-rate-limiter/useAsyncRateLimitedCallback.ts @@ -59,10 +59,10 @@ import type { ReactAsyncRateLimiterOptions } from './useAsyncRateLimiter' export function useAsyncRateLimitedCallback( fn: TFn, options: ReactAsyncRateLimiterOptions, -): (...args: Parameters) => Promise> { +): (...args: Parameters) => Promise> | undefined> { const asyncRateLimitedFn = useAsyncRateLimiter(fn, options).maybeExecute return useCallback( - (...args) => asyncRateLimitedFn(...args) as Promise>, + (...args) => asyncRateLimitedFn(...args), [asyncRateLimitedFn], ) } diff --git a/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts b/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts index d1a8bdd70..204cc77c1 100644 --- a/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts +++ b/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts @@ -12,7 +12,8 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * regardless of how many times it is called. If called multiple times during the wait period, * only the first invocation will execute, and subsequent calls will be ignored until * the wait period has elapsed. The returned function always returns a promise - * that resolves or rejects with the result of the original async function. + * that resolves or rejects with the result of the original async function, or + * resolves with `undefined` if the call was throttled or the throttler is disabled. * * This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic * async throttling needs. However, it does not expose the underlying AsyncThrottler instance. @@ -42,10 +43,7 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' export function useAsyncThrottledCallback( fn: TFn, options: ReactAsyncThrottlerOptions, -): (...args: Parameters) => Promise> { +): (...args: Parameters) => Promise> | undefined> { const asyncThrottledFn = useAsyncThrottler(fn, options).maybeExecute - return useCallback( - (...args) => asyncThrottledFn(...args) as Promise>, - [asyncThrottledFn], - ) + return useCallback((...args) => asyncThrottledFn(...args), [asyncThrottledFn]) } From aaf064b3290f9a7b6773ed969ac9ba488cd88c81 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Thu, 6 Aug 2026 18:19:11 -0500 Subject: [PATCH 2/4] fix: flush error-path stalls, duplicate-item concurrency accounting, and callback docs Addresses review feedback: flush()/flushAsBatch() now restore the tick chain even when a task or batch function rejects (flush uses allSettled and rethrows after state restoration, with remaining items resuming at the normal wait spacing); execute() removes a single activeItems occurrence so duplicate item values cannot exceed the concurrency limit; react/preact async callback docstrings no longer claim throttled/superseded calls resolve with undefined. Co-Authored-By: Claude Fable 5 --- .../functions/useAsyncDebouncedCallback.md | 9 +- .../functions/useAsyncThrottledCallback.md | 10 +- .../functions/useAsyncDebouncedCallback.md | 9 +- .../functions/useAsyncThrottledCallback.md | 10 +- docs/reference/classes/AsyncQueuer.md | 24 +- docs/reference/functions/asyncQueue.md | 2 +- packages/pacer/src/async-queuer.ts | 46 +++- packages/pacer/tests/async-queuer.test.ts | 249 ++++++++++++++++++ .../useAsyncDebouncedCallback.ts | 7 +- .../useAsyncThrottledCallback.ts | 10 +- .../useAsyncDebouncedCallback.ts | 7 +- .../useAsyncThrottledCallback.ts | 10 +- 12 files changed, 338 insertions(+), 55 deletions(-) diff --git a/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md b/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md index 1646e1a33..934fd599c 100644 --- a/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md +++ b/docs/framework/preact/reference/functions/useAsyncDebouncedCallback.md @@ -9,7 +9,7 @@ title: useAsyncDebouncedCallback function useAsyncDebouncedCallback(fn, options): (...args) => Promise> | undefined>; ``` -Defined in: [preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:45](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L45) +Defined in: [preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:46](https://github.com/TanStack/pacer/blob/main/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L46) A Preact hook that creates a debounced version of an async callback function. This hook is a convenient wrapper around the `useAsyncDebouncer` hook, @@ -17,9 +17,10 @@ providing a stable, debounced async function reference for use in Preact compone The debounced async function will only execute after the specified wait time has elapsed since its last invocation. If called again before the wait time expires, the timer -resets and starts waiting again. The returned function always returns a promise -that resolves or rejects with the result of the original async function, or -resolves with `undefined` if the call was superseded or the debouncer is disabled. +resets and starts waiting again. The returned function always returns a promise. The call +that triggers an execution resolves or rejects with that execution's result; superseded +calls resolve with the most recent result (which may be `undefined` if nothing has executed +yet), and calls made while the debouncer is disabled resolve with `undefined`. This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. diff --git a/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md b/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md index e1a358f37..dd6b56924 100644 --- a/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md +++ b/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md @@ -16,11 +16,11 @@ This hook is a convenient wrapper around the `useAsyncThrottler` hook, providing a stable, throttled async function reference for use in Preact components. The throttled async function will execute at most once within the specified wait time period, -regardless of how many times it is called. If called multiple times during the wait period, -only the first invocation will execute, and subsequent calls will be ignored until -the wait period has elapsed. The returned function always returns a promise -that resolves or rejects with the result of the original async function, or -resolves with `undefined` if the call was throttled or the throttler is disabled. +regardless of how many times it is called. Calls made during the wait period can schedule a +single trailing execution with the latest arguments when `trailing` is enabled (the default), +and those calls share the trailing execution's result. The returned function always returns +a promise that resolves or rejects with the result of the original async function, and +resolves with `undefined` when the throttler is disabled. This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic async throttling needs. However, it does not expose the underlying AsyncThrottler instance. diff --git a/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md b/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md index d09725dc6..2a2c691f5 100644 --- a/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md +++ b/docs/framework/react/reference/functions/useAsyncDebouncedCallback.md @@ -9,7 +9,7 @@ title: useAsyncDebouncedCallback function useAsyncDebouncedCallback(fn, options): (...args) => Promise> | undefined>; ``` -Defined in: [react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:45](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L45) +Defined in: [react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts:46](https://github.com/TanStack/pacer/blob/main/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts#L46) A React hook that creates a debounced version of an async callback function. This hook is a convenient wrapper around the `useAsyncDebouncer` hook, @@ -17,9 +17,10 @@ providing a stable, debounced async function reference for use in React componen The debounced async function will only execute after the specified wait time has elapsed since its last invocation. If called again before the wait time expires, the timer -resets and starts waiting again. The returned function always returns a promise -that resolves or rejects with the result of the original async function, or -resolves with `undefined` if the call was superseded or the debouncer is disabled. +resets and starts waiting again. The returned function always returns a promise. The call +that triggers an execution resolves or rejects with that execution's result; superseded +calls resolve with the most recent result (which may be `undefined` if nothing has executed +yet), and calls made while the debouncer is disabled resolve with `undefined`. This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. diff --git a/docs/framework/react/reference/functions/useAsyncThrottledCallback.md b/docs/framework/react/reference/functions/useAsyncThrottledCallback.md index c57a68875..ddcba5626 100644 --- a/docs/framework/react/reference/functions/useAsyncThrottledCallback.md +++ b/docs/framework/react/reference/functions/useAsyncThrottledCallback.md @@ -16,11 +16,11 @@ This hook is a convenient wrapper around the `useAsyncThrottler` hook, providing a stable, throttled async function reference for use in React components. The throttled async function will execute at most once within the specified wait time period, -regardless of how many times it is called. If called multiple times during the wait period, -only the first invocation will execute, and subsequent calls will be ignored until -the wait period has elapsed. The returned function always returns a promise -that resolves or rejects with the result of the original async function, or -resolves with `undefined` if the call was throttled or the throttler is disabled. +regardless of how many times it is called. Calls made during the wait period can schedule a +single trailing execution with the latest arguments when `trailing` is enabled (the default), +and those calls share the trailing execution's result. The returned function always returns +a promise that resolves or rejects with the result of the original async function, and +resolves with `undefined` when the throttler is disabled. This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic async throttling needs. However, it does not expose the underlying AsyncThrottler instance. diff --git a/docs/reference/classes/AsyncQueuer.md b/docs/reference/classes/AsyncQueuer.md index f8c440f11..d05f63b44 100644 --- a/docs/reference/classes/AsyncQueuer.md +++ b/docs/reference/classes/AsyncQueuer.md @@ -168,7 +168,7 @@ Defined in: [async-queuer.ts:316](https://github.com/TanStack/pacer/blob/main/pa abort(): void; ``` -Defined in: [async-queuer.ts:857](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L857) +Defined in: [async-queuer.ts:887](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L887) Aborts all ongoing executions with the internal abort controllers. Does NOT clear out the items. @@ -226,7 +226,7 @@ queuer.addItem('task2', 'front'); clear(): void; ``` -Defined in: [async-queuer.ts:822](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L822) +Defined in: [async-queuer.ts:852](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L852) Removes all pending items from the queue. Does NOT affect active tasks. @@ -273,7 +273,7 @@ queuer.execute('back'); flush(numberOfItems, position?): Promise; ``` -Defined in: [async-queuer.ts:672](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L672) +Defined in: [async-queuer.ts:677](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L677) Processes a specified number of items to execute immediately with no wait time If no numberOfItems is provided, all items will be processed @@ -300,7 +300,7 @@ If no numberOfItems is provided, all items will be processed flushAsBatch(batchFunction): Promise; ``` -Defined in: [async-queuer.ts:691](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L691) +Defined in: [async-queuer.ts:714](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L714) Processes all items in the queue as a batch using the provided function as an argument The queue is cleared after processing @@ -323,7 +323,7 @@ The queue is cleared after processing getAbortSignal(executeCount?): AbortSignal | null; ``` -Defined in: [async-queuer.ts:847](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L847) +Defined in: [async-queuer.ts:877](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L877) Returns the AbortSignal for a specific execution. If no executeCount is provided, returns the signal for the most recent execution. @@ -396,7 +396,7 @@ queuer.getNextItem('back'); peekActiveItems(): TValue[]; ``` -Defined in: [async-queuer.ts:784](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L784) +Defined in: [async-queuer.ts:814](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L814) Returns the items currently being processed (active tasks). @@ -412,7 +412,7 @@ Returns the items currently being processed (active tasks). peekAllItems(): TValue[]; ``` -Defined in: [async-queuer.ts:777](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L777) +Defined in: [async-queuer.ts:807](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L807) Returns a copy of all items in the queue, including active and pending items. @@ -428,7 +428,7 @@ Returns a copy of all items in the queue, including active and pending items. peekNextItem(position): TValue | undefined; ``` -Defined in: [async-queuer.ts:767](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L767) +Defined in: [async-queuer.ts:797](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L797) Returns the next item in the queue without removing it. @@ -457,7 +457,7 @@ queuer.peekNextItem('back'); // back peekPendingItems(): TValue[]; ``` -Defined in: [async-queuer.ts:791](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L791) +Defined in: [async-queuer.ts:821](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L821) Returns the items waiting to be processed (pending tasks). @@ -473,7 +473,7 @@ Returns the items waiting to be processed (pending tasks). reset(): void; ``` -Defined in: [async-queuer.ts:868](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L868) +Defined in: [async-queuer.ts:898](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L898) Resets the queuer state to its default values @@ -511,7 +511,7 @@ Updates the queuer options. New options are merged with existing options. start(): void; ``` -Defined in: [async-queuer.ts:798](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L798) +Defined in: [async-queuer.ts:828](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L828) Starts processing items in the queue. If already running, does nothing. @@ -527,7 +527,7 @@ Starts processing items in the queue. If already running, does nothing. stop(): void; ``` -Defined in: [async-queuer.ts:808](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L808) +Defined in: [async-queuer.ts:838](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L838) Stops processing items in the queue. Does not clear the queue. diff --git a/docs/reference/functions/asyncQueue.md b/docs/reference/functions/asyncQueue.md index 50c5479d3..b47e27fe7 100644 --- a/docs/reference/functions/asyncQueue.md +++ b/docs/reference/functions/asyncQueue.md @@ -9,7 +9,7 @@ title: asyncQueue function asyncQueue(fn, initialOptions): (item, position, runOnItemsChange) => boolean; ``` -Defined in: [async-queuer.ts:940](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L940) +Defined in: [async-queuer.ts:970](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L970) Creates a new AsyncQueuer instance and returns a bound addItem function for adding tasks. The queuer is started automatically and ready to process items. diff --git a/packages/pacer/src/async-queuer.ts b/packages/pacer/src/async-queuer.ts index 02ac47e8b..18a4d88ad 100644 --- a/packages/pacer/src/async-queuer.ts +++ b/packages/pacer/src/async-queuer.ts @@ -652,10 +652,15 @@ export class AsyncQueuer { } } finally { this.asyncRetryers.delete(currentExecuteCount) // dispose retryer + // remove only one occurrence so duplicate item values keep accurate + // concurrency accounting + const remainingActiveItems = [...this.store.state.activeItems] + const activeItemIndex = remainingActiveItems.indexOf(item) + if (activeItemIndex !== -1) { + remainingActiveItems.splice(activeItemIndex, 1) + } this.#setState({ - activeItems: this.store.state.activeItems.filter( - (activeItem) => activeItem !== item, - ), + activeItems: remainingActiveItems, isExecuting: false, settledCount: this.store.state.settledCount + 1, }) @@ -674,13 +679,31 @@ export class AsyncQueuer { position?: QueuePosition, ): Promise => { this.#clearTimeouts() // clear any pending timeouts (kills the tick chain) - await Promise.all( + const results = await Promise.allSettled( Array.from({ length: numberOfItems }, () => this.execute(position)), ) - // the tick chain was killed above; restart it so remaining and future items process + // the tick chain was killed above; restart it so remaining and future items + // process even when a flushed task rejected. Remaining items resume with the + // normal wait spacing after the flushed executions. this.#setState({ pendingTick: false }) if (this.store.state.isRunning && this.store.state.items.length > 0) { - this.#tick() + const wait = this.#getWait() + if (wait > 0) { + this.#setState({ pendingTick: true }) + const timeoutId = setTimeout(() => { + this.#timeoutIds.delete(timeoutId) + this.#tick() + }, wait) + this.#timeoutIds.add(timeoutId) + } else { + this.#tick() + } + } + const failure = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ) + if (failure) { + throw failure.reason } } @@ -693,8 +716,15 @@ export class AsyncQueuer { ): Promise => { this.#clearTimeouts() // clear any pending timeouts (kills the tick chain) const items = this.#getAllItems() - await batchFunction(items) - this.#setState({ pendingTick: false }) + try { + await batchFunction(items) + } finally { + // restore the tick chain even when the batch function rejects + this.#setState({ pendingTick: false }) + if (this.store.state.isRunning && this.store.state.items.length > 0) { + this.#tick() + } + } } /** diff --git a/packages/pacer/tests/async-queuer.test.ts b/packages/pacer/tests/async-queuer.test.ts index 3bdcb1468..58425f3da 100644 --- a/packages/pacer/tests/async-queuer.test.ts +++ b/packages/pacer/tests/async-queuer.test.ts @@ -1341,4 +1341,253 @@ describe('AsyncQueuer', () => { expect(getPacerDevtoolsInstance('my-queuer')).toBeDefined() // the queuer itself still registers }) }) + + describe('flush robustness', () => { + it('should continue processing after flush() rejects during a wait window', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + if (item === 'bad') throw new Error('boom') + results.push(item) + return item + }, + { wait: 100, started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('bad') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) // 'a' done, wait timer pending + expect(results).toEqual(['a']) + + await expect(asyncQueuer.flush()).rejects.toThrow('boom') + expect(asyncQueuer.store.state.errorCount).toBe(1) + + asyncQueuer.addItem('c') + await vi.advanceTimersByTimeAsync(300) + expect(results).toEqual(['a', 'c']) + }) + + it('should flush remaining items even when an earlier flushed item rejects', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + if (item === 'bad') throw new Error('boom') + results.push(item) + return item + }, + { started: false }, + ) + + asyncQueuer.addItem('bad') + asyncQueuer.addItem('x') + asyncQueuer.addItem('y') + + await expect(asyncQueuer.flush()).rejects.toThrow('boom') + + // allSettled semantics: the non-failing items still executed + expect(results).toEqual(['x', 'y']) + expect(asyncQueuer.store.state.errorCount).toBe(1) + expect(asyncQueuer.store.state.settledCount).toBe(3) + }) + + it('should process items remaining after a partial flush with wait > 0', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { wait: 100, started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + asyncQueuer.addItem('c') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) // 'a' done, wait timer pending + await asyncQueuer.flush(1) // flush only 'b' + expect(results).toEqual(['a', 'b']) + + // 'c' must be picked up by the restarted chain + await vi.advanceTimersByTimeAsync(300) + expect(results).toEqual(['a', 'b', 'c']) + }) + + it('should continue processing after flushAsBatch() rejects', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { wait: 100, started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + await expect( + asyncQueuer.flushAsBatch(async () => { + throw new Error('batch boom') + }), + ).rejects.toThrow('batch boom') + + asyncQueuer.addItem('c') + await vi.advanceTimersByTimeAsync(300) + expect(results).toEqual(['a', 'c']) + }) + + it('should process items added while flushAsBatch is awaiting', async () => { + const batches: Array> = [] + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + + const batchPromise = asyncQueuer.flushAsBatch(async (items) => { + batches.push(items) + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + asyncQueuer.addItem('late') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(50) + await batchPromise + await vi.advanceTimersByTimeAsync(100) + + expect(batches).toEqual([['a', 'b']]) + expect(results).toEqual(['late']) + }) + + it('should not restart processing when flushing a stopped queuer', async () => { + const results: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + results.push(item) + return item + }, + { started: false }, + ) + + asyncQueuer.addItem('a') + asyncQueuer.addItem('b') + asyncQueuer.addItem('c') + + await asyncQueuer.flush(2) + expect(results).toEqual(['a', 'b']) + expect(asyncQueuer.store.state.isRunning).toBe(false) + expect(asyncQueuer.store.state.items).toEqual(['c']) + + await vi.advanceTimersByTimeAsync(100) + expect(results).toEqual(['a', 'b']) // still stopped + + asyncQueuer.start() + await vi.advanceTimersByTimeAsync(0) + expect(results).toEqual(['a', 'b', 'c']) + }) + }) + + describe('concurrency accounting with duplicate items', () => { + it('should never exceed concurrency when duplicate primitive items complete', async () => { + let active = 0 + let maxActive = 0 + const resolvers: Array<() => void> = [] + const asyncQueuer = new AsyncQueuer( + (item) => { + active++ + maxActive = Math.max(maxActive, active) + return new Promise((resolve) => { + resolvers.push(() => { + active-- + resolve(item) + }) + }) + }, + { concurrency: 2, started: false }, + ) + + asyncQueuer.addItem(0) + asyncQueuer.addItem(0) + asyncQueuer.addItem(1) + asyncQueuer.addItem(2) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + expect(maxActive).toBe(2) + + resolvers.shift()!() // first 0 completes while the second 0 is still active + await vi.advanceTimersByTimeAsync(0) + expect(maxActive).toBe(2) + + while (resolvers.length > 0) { + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + } + expect(maxActive).toBe(2) + expect(asyncQueuer.store.state.successCount).toBe(4) + }) + + it('should keep activeItems accurate when a duplicate completes', async () => { + const resolvers: Array<() => void> = [] + const asyncQueuer = new AsyncQueuer( + (item) => { + return new Promise((resolve) => { + resolvers.push(() => resolve(item)) + }) + }, + { concurrency: 2, started: false }, + ) + + asyncQueuer.addItem(7) + asyncQueuer.addItem(7) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + expect(asyncQueuer.peekActiveItems()).toEqual([7, 7]) + + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + expect(asyncQueuer.peekActiveItems()).toEqual([7]) // only ONE removed + + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + expect(asyncQueuer.peekActiveItems()).toEqual([]) + }) + + it('should process duplicate falsy items with concurrency > 1', async () => { + const processed: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + processed.push(item) + return item + }, + { concurrency: 3, started: false }, + ) + + asyncQueuer.addItem(0) + asyncQueuer.addItem('') + asyncQueuer.addItem(0) + asyncQueuer.addItem(false) + asyncQueuer.addItem('') + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(100) + + expect(processed).toEqual([0, '', 0, false, '']) + expect(asyncQueuer.store.state.successCount).toBe(5) + expect(asyncQueuer.store.state.activeItems).toEqual([]) + }) + }) }) diff --git a/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts b/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts index c3e279864..04e19476c 100644 --- a/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts +++ b/packages/preact-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts @@ -10,9 +10,10 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * * The debounced async function will only execute after the specified wait time has elapsed * since its last invocation. If called again before the wait time expires, the timer - * resets and starts waiting again. The returned function always returns a promise - * that resolves or rejects with the result of the original async function, or - * resolves with `undefined` if the call was superseded or the debouncer is disabled. + * resets and starts waiting again. The returned function always returns a promise. The call + * that triggers an execution resolves or rejects with that execution's result; superseded + * calls resolve with the most recent result (which may be `undefined` if nothing has executed + * yet), and calls made while the debouncer is disabled resolve with `undefined`. * * This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic * async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. diff --git a/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts b/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts index 571004234..1054c7960 100644 --- a/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts +++ b/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts @@ -9,11 +9,11 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * providing a stable, throttled async function reference for use in Preact components. * * The throttled async function will execute at most once within the specified wait time period, - * regardless of how many times it is called. If called multiple times during the wait period, - * only the first invocation will execute, and subsequent calls will be ignored until - * the wait period has elapsed. The returned function always returns a promise - * that resolves or rejects with the result of the original async function, or - * resolves with `undefined` if the call was throttled or the throttler is disabled. + * regardless of how many times it is called. Calls made during the wait period can schedule a + * single trailing execution with the latest arguments when `trailing` is enabled (the default), + * and those calls share the trailing execution's result. The returned function always returns + * a promise that resolves or rejects with the result of the original async function, and + * resolves with `undefined` when the throttler is disabled. * * This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic * async throttling needs. However, it does not expose the underlying AsyncThrottler instance. diff --git a/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts b/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts index 1742c57ab..e09f71d9b 100644 --- a/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts +++ b/packages/react-pacer/src/async-debouncer/useAsyncDebouncedCallback.ts @@ -10,9 +10,10 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * * The debounced async function will only execute after the specified wait time has elapsed * since its last invocation. If called again before the wait time expires, the timer - * resets and starts waiting again. The returned function always returns a promise - * that resolves or rejects with the result of the original async function, or - * resolves with `undefined` if the call was superseded or the debouncer is disabled. + * resets and starts waiting again. The returned function always returns a promise. The call + * that triggers an execution resolves or rejects with that execution's result; superseded + * calls resolve with the most recent result (which may be `undefined` if nothing has executed + * yet), and calls made while the debouncer is disabled resolve with `undefined`. * * This hook provides a simpler API compared to `useAsyncDebouncer`, making it ideal for basic * async debouncing needs. However, it does not expose the underlying AsyncDebouncer instance. diff --git a/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts b/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts index 204cc77c1..ee95367e9 100644 --- a/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts +++ b/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts @@ -9,11 +9,11 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * providing a stable, throttled async function reference for use in React components. * * The throttled async function will execute at most once within the specified wait time period, - * regardless of how many times it is called. If called multiple times during the wait period, - * only the first invocation will execute, and subsequent calls will be ignored until - * the wait period has elapsed. The returned function always returns a promise - * that resolves or rejects with the result of the original async function, or - * resolves with `undefined` if the call was throttled or the throttler is disabled. + * regardless of how many times it is called. Calls made during the wait period can schedule a + * single trailing execution with the latest arguments when `trailing` is enabled (the default), + * and those calls share the trailing execution's result. The returned function always returns + * a promise that resolves or rejects with the result of the original async function, and + * resolves with `undefined` when the throttler is disabled. * * This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic * async throttling needs. However, it does not expose the underlying AsyncThrottler instance. From 58a9745dd49a1e384cf9a51086c5b6e66c65e9d1 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Thu, 6 Aug 2026 18:26:29 -0500 Subject: [PATCH 3/4] fix: reject undefined items in AsyncQueuer and Queuer undefined is the internal "no item" sentinel used by peekNextItem/getNextItem, so an enqueued undefined item would wedge the processing loop and block every item behind it. addItem now rejects undefined through the standard rejection path (rejectionCount + onReject + return false) in both queuers. Co-Authored-By: Claude Fable 5 --- .../reference/interfaces/AsyncQueuedSignal.md | 1 + .../reference/interfaces/QueuedSignal.md | 1 + docs/reference/classes/AsyncQueuer.md | 31 ++++++++++--------- docs/reference/classes/Queuer.md | 23 +++++++------- docs/reference/functions/asyncQueue.md | 3 +- docs/reference/functions/queue.md | 3 +- packages/pacer/src/async-queuer.ts | 11 +++++++ packages/pacer/src/queuer.ts | 11 +++++++ packages/pacer/tests/async-queuer.test.ts | 25 +++++++++++++++ packages/pacer/tests/queuer.test.ts | 18 +++++++++++ 10 files changed, 99 insertions(+), 28 deletions(-) diff --git a/docs/framework/angular/reference/interfaces/AsyncQueuedSignal.md b/docs/framework/angular/reference/interfaces/AsyncQueuedSignal.md index 889c95eab..11d0c7193 100644 --- a/docs/framework/angular/reference/interfaces/AsyncQueuedSignal.md +++ b/docs/framework/angular/reference/interfaces/AsyncQueuedSignal.md @@ -40,6 +40,7 @@ Defined in: [angular-pacer/src/async-queuer/injectAsyncQueuedSignal.ts:11](https Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. Items can be inserted based on priority or at the front/back depending on configuration. +`undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. #### Parameters diff --git a/docs/framework/angular/reference/interfaces/QueuedSignal.md b/docs/framework/angular/reference/interfaces/QueuedSignal.md index 3e2842f51..a9c5533f5 100644 --- a/docs/framework/angular/reference/interfaces/QueuedSignal.md +++ b/docs/framework/angular/reference/interfaces/QueuedSignal.md @@ -39,6 +39,7 @@ addItem: (item, position?, runOnItemsChange?) => boolean; Defined in: [angular-pacer/src/queuer/injectQueuedSignal.ts:8](https://github.com/TanStack/pacer/blob/main/packages/angular-pacer/src/queuer/injectQueuedSignal.ts#L8) Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. +`undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. Items can be inserted based on priority or at the front/back depending on configuration. Returns true if the item was added, false if the queue is full. diff --git a/docs/reference/classes/AsyncQueuer.md b/docs/reference/classes/AsyncQueuer.md index d05f63b44..178d1aa76 100644 --- a/docs/reference/classes/AsyncQueuer.md +++ b/docs/reference/classes/AsyncQueuer.md @@ -168,7 +168,7 @@ Defined in: [async-queuer.ts:316](https://github.com/TanStack/pacer/blob/main/pa abort(): void; ``` -Defined in: [async-queuer.ts:887](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L887) +Defined in: [async-queuer.ts:898](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L898) Aborts all ongoing executions with the internal abort controllers. Does NOT clear out the items. @@ -188,10 +188,11 @@ addItem( runOnItemsChange): boolean; ``` -Defined in: [async-queuer.ts:489](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L489) +Defined in: [async-queuer.ts:490](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L490) Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. Items can be inserted based on priority or at the front/back depending on configuration. +`undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. #### Parameters @@ -226,7 +227,7 @@ queuer.addItem('task2', 'front'); clear(): void; ``` -Defined in: [async-queuer.ts:852](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L852) +Defined in: [async-queuer.ts:863](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L863) Removes all pending items from the queue. Does NOT affect active tasks. @@ -243,7 +244,7 @@ Does NOT affect active tasks. execute(position?): Promise; ``` -Defined in: [async-queuer.ts:624](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L624) +Defined in: [async-queuer.ts:635](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L635) Removes and returns the next item from the queue and executes the task function with it. @@ -273,7 +274,7 @@ queuer.execute('back'); flush(numberOfItems, position?): Promise; ``` -Defined in: [async-queuer.ts:677](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L677) +Defined in: [async-queuer.ts:688](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L688) Processes a specified number of items to execute immediately with no wait time If no numberOfItems is provided, all items will be processed @@ -300,7 +301,7 @@ If no numberOfItems is provided, all items will be processed flushAsBatch(batchFunction): Promise; ``` -Defined in: [async-queuer.ts:714](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L714) +Defined in: [async-queuer.ts:725](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L725) Processes all items in the queue as a batch using the provided function as an argument The queue is cleared after processing @@ -323,7 +324,7 @@ The queue is cleared after processing getAbortSignal(executeCount?): AbortSignal | null; ``` -Defined in: [async-queuer.ts:877](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L877) +Defined in: [async-queuer.ts:888](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L888) Returns the AbortSignal for a specific execution. If no executeCount is provided, returns the signal for the most recent execution. @@ -364,7 +365,7 @@ const queuer = new AsyncQueuer( getNextItem(position): TValue | undefined; ``` -Defined in: [async-queuer.ts:572](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L572) +Defined in: [async-queuer.ts:583](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L583) Removes and returns the next item from the queue without executing the task function. Use for manual queue management. Normally, use execute() to process items. @@ -396,7 +397,7 @@ queuer.getNextItem('back'); peekActiveItems(): TValue[]; ``` -Defined in: [async-queuer.ts:814](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L814) +Defined in: [async-queuer.ts:825](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L825) Returns the items currently being processed (active tasks). @@ -412,7 +413,7 @@ Returns the items currently being processed (active tasks). peekAllItems(): TValue[]; ``` -Defined in: [async-queuer.ts:807](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L807) +Defined in: [async-queuer.ts:818](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L818) Returns a copy of all items in the queue, including active and pending items. @@ -428,7 +429,7 @@ Returns a copy of all items in the queue, including active and pending items. peekNextItem(position): TValue | undefined; ``` -Defined in: [async-queuer.ts:797](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L797) +Defined in: [async-queuer.ts:808](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L808) Returns the next item in the queue without removing it. @@ -457,7 +458,7 @@ queuer.peekNextItem('back'); // back peekPendingItems(): TValue[]; ``` -Defined in: [async-queuer.ts:821](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L821) +Defined in: [async-queuer.ts:832](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L832) Returns the items waiting to be processed (pending tasks). @@ -473,7 +474,7 @@ Returns the items waiting to be processed (pending tasks). reset(): void; ``` -Defined in: [async-queuer.ts:898](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L898) +Defined in: [async-queuer.ts:909](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L909) Resets the queuer state to its default values @@ -511,7 +512,7 @@ Updates the queuer options. New options are merged with existing options. start(): void; ``` -Defined in: [async-queuer.ts:828](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L828) +Defined in: [async-queuer.ts:839](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L839) Starts processing items in the queue. If already running, does nothing. @@ -527,7 +528,7 @@ Starts processing items in the queue. If already running, does nothing. stop(): void; ``` -Defined in: [async-queuer.ts:838](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L838) +Defined in: [async-queuer.ts:849](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L849) Stops processing items in the queue. Does not clear the queue. diff --git a/docs/reference/classes/Queuer.md b/docs/reference/classes/Queuer.md index 9474120db..3477e3d64 100644 --- a/docs/reference/classes/Queuer.md +++ b/docs/reference/classes/Queuer.md @@ -171,9 +171,10 @@ addItem( runOnItemsChange): boolean; ``` -Defined in: [queuer.ts:403](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L403) +Defined in: [queuer.ts:404](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L404) Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. +`undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. Items can be inserted based on priority or at the front/back depending on configuration. Returns true if the item was added, false if the queue is full. @@ -210,7 +211,7 @@ queuer.addItem('task2', 'front'); clear(): void; ``` -Defined in: [queuer.ts:685](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L685) +Defined in: [queuer.ts:696](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L696) Removes all pending items from the queue. Does not affect items being processed. @@ -226,7 +227,7 @@ Removes all pending items from the queue. Does not affect items being processed. execute(position?): TValue | undefined; ``` -Defined in: [queuer.ts:539](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L539) +Defined in: [queuer.ts:550](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L550) Removes and returns the next item from the queue and processes it using the provided function. @@ -255,7 +256,7 @@ queuer.execute('back'); flush(numberOfItems, position?): void; ``` -Defined in: [queuer.ts:555](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L555) +Defined in: [queuer.ts:566](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L566) Processes a specified number of items to execute immediately with no wait time If no numberOfItems is provided, all items will be processed @@ -282,7 +283,7 @@ If no numberOfItems is provided, all items will be processed flushAsBatch(batchFunction): void; ``` -Defined in: [queuer.ts:570](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L570) +Defined in: [queuer.ts:581](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L581) Processes all items in the queue as a batch using the provided function as an argument The queue is cleared after processing @@ -305,7 +306,7 @@ The queue is cleared after processing getNextItem(position): TValue | undefined; ``` -Defined in: [queuer.ts:487](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L487) +Defined in: [queuer.ts:498](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L498) Removes and returns the next item from the queue without executing the function. Use for manual queue management. Normally, use execute() to process items. @@ -336,7 +337,7 @@ queuer.getNextItem('back'); peekAllItems(): TValue[]; ``` -Defined in: [queuer.ts:653](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L653) +Defined in: [queuer.ts:664](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L664) Returns a copy of all items in the queue. @@ -352,7 +353,7 @@ Returns a copy of all items in the queue. peekNextItem(position): TValue | undefined; ``` -Defined in: [queuer.ts:643](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L643) +Defined in: [queuer.ts:654](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L654) Returns the next item in the queue without removing it. @@ -380,7 +381,7 @@ queuer.peekNextItem('back'); // back reset(): void; ``` -Defined in: [queuer.ts:693](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L693) +Defined in: [queuer.ts:704](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L704) Resets the queuer state to its default values @@ -418,7 +419,7 @@ Updates the queuer options. New options are merged with existing options. start(): void; ``` -Defined in: [queuer.ts:660](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L660) +Defined in: [queuer.ts:671](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L671) Starts processing items in the queue. If already isRunning, does nothing. @@ -434,7 +435,7 @@ Starts processing items in the queue. If already isRunning, does nothing. stop(): void; ``` -Defined in: [queuer.ts:670](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L670) +Defined in: [queuer.ts:681](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L681) Stops processing items in the queue. Does not clear the queue. diff --git a/docs/reference/functions/asyncQueue.md b/docs/reference/functions/asyncQueue.md index b47e27fe7..61fb4e1b8 100644 --- a/docs/reference/functions/asyncQueue.md +++ b/docs/reference/functions/asyncQueue.md @@ -9,7 +9,7 @@ title: asyncQueue function asyncQueue(fn, initialOptions): (item, position, runOnItemsChange) => boolean; ``` -Defined in: [async-queuer.ts:970](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L970) +Defined in: [async-queuer.ts:981](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L981) Creates a new AsyncQueuer instance and returns a bound addItem function for adding tasks. The queuer is started automatically and ready to process items. @@ -89,6 +89,7 @@ State Management: Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. Items can be inserted based on priority or at the front/back depending on configuration. +`undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. ### Parameters diff --git a/docs/reference/functions/queue.md b/docs/reference/functions/queue.md index 97597177c..a6a7858b8 100644 --- a/docs/reference/functions/queue.md +++ b/docs/reference/functions/queue.md @@ -9,7 +9,7 @@ title: queue function queue(fn, initialOptions): (item, position, runOnItemsChange) => boolean; ``` -Defined in: [queuer.ts:734](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L734) +Defined in: [queuer.ts:745](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/queuer.ts#L745) Creates a queue that processes items immediately upon addition. Items are processed sequentially in FIFO order by default. @@ -71,6 +71,7 @@ processPriority(3); // Processed before 1 ``` Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. +`undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. Items can be inserted based on priority or at the front/back depending on configuration. Returns true if the item was added, false if the queue is full. diff --git a/packages/pacer/src/async-queuer.ts b/packages/pacer/src/async-queuer.ts index 18a4d88ad..a6510421a 100644 --- a/packages/pacer/src/async-queuer.ts +++ b/packages/pacer/src/async-queuer.ts @@ -479,6 +479,7 @@ export class AsyncQueuer { /** * Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. * Items can be inserted based on priority or at the front/back depending on configuration. + * `undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. * * @example * ```ts @@ -495,6 +496,16 @@ export class AsyncQueuer { addItemCount: this.store.state.addItemCount + 1, }) + // undefined is the internal "no item" sentinel (peekNextItem/getNextItem); + // queuing it would wedge the processing loop and block items behind it + if (item === undefined) { + this.#setState({ + rejectionCount: this.store.state.rejectionCount + 1, + }) + this.options.onReject?.(item, this) + return false + } + if (this.store.state.items.length >= (this.options.maxSize ?? Infinity)) { this.#setState({ rejectionCount: this.store.state.rejectionCount + 1, diff --git a/packages/pacer/src/queuer.ts b/packages/pacer/src/queuer.ts index 799bca51e..1490d1d95 100644 --- a/packages/pacer/src/queuer.ts +++ b/packages/pacer/src/queuer.ts @@ -390,6 +390,7 @@ export class Queuer { /** * Adds an item to the queue. If the queue is full, the item is rejected and onReject is called. + * `undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected. * Items can be inserted based on priority or at the front/back depending on configuration. * * Returns true if the item was added, false if the queue is full. @@ -409,6 +410,16 @@ export class Queuer { addItemCount: this.store.state.addItemCount + 1, }) + // undefined is the internal "no item" sentinel (peekNextItem/getNextItem); + // queuing it would break the processing loop and block items behind it + if (item === undefined) { + this.#setState({ + rejectionCount: this.store.state.rejectionCount + 1, + }) + this.options.onReject?.(item, this) + return false + } + if (this.store.state.items.length >= (this.options.maxSize ?? Infinity)) { this.#setState({ rejectionCount: this.store.state.rejectionCount + 1, diff --git a/packages/pacer/tests/async-queuer.test.ts b/packages/pacer/tests/async-queuer.test.ts index 58425f3da..a182e8c0b 100644 --- a/packages/pacer/tests/async-queuer.test.ts +++ b/packages/pacer/tests/async-queuer.test.ts @@ -1137,6 +1137,31 @@ describe('AsyncQueuer', () => { expect(asyncQueuer.store.state.successCount).toBe(1) }) + it('should reject undefined items instead of wedging the queue', async () => { + const onReject = vi.fn() + const processed: Array = [] + const asyncQueuer = new AsyncQueuer( + async (item) => { + processed.push(item) + return item + }, + { started: false, onReject }, + ) + + // undefined is the internal "no item" sentinel; queuing it would block + // every item behind it in the processing loop + expect(asyncQueuer.addItem(undefined)).toBe(false) + expect(onReject).toHaveBeenCalledWith(undefined, asyncQueuer) + expect(asyncQueuer.store.state.rejectionCount).toBe(1) + expect(asyncQueuer.store.state.items).toEqual([]) + + asyncQueuer.addItem('a') + asyncQueuer.start() + await vi.advanceTimersByTimeAsync(100) + + expect(processed).toEqual(['a']) + }) + it('should interleave falsy items with truthy items in order', async () => { const processed: Array = [] const asyncQueuer = new AsyncQueuer( diff --git a/packages/pacer/tests/queuer.test.ts b/packages/pacer/tests/queuer.test.ts index 9e25e2bc6..e8dcf7ec5 100644 --- a/packages/pacer/tests/queuer.test.ts +++ b/packages/pacer/tests/queuer.test.ts @@ -548,5 +548,23 @@ describe('Queuer', () => { expect(processed).toEqual([null]) }) + + it('should reject undefined items instead of wedging the queue', () => { + const onReject = vi.fn() + const processed: Array = [] + const queuer = new Queuer((item) => processed.push(item), { + started: false, + onReject, + }) + + expect(queuer.addItem(undefined)).toBe(false) + expect(onReject).toHaveBeenCalledWith(undefined, queuer) + expect(queuer.store.state.rejectionCount).toBe(1) + + queuer.addItem('a') + queuer.start() + + expect(processed).toEqual(['a']) + }) }) }) From edb6b61c4503a472ecfce975f1a8eff97c2ff91e Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Thu, 6 Aug 2026 18:34:44 -0500 Subject: [PATCH 4/4] fix: activeItems immutability, accurate isExecuting with overlapping executions, and throttled callback docs #tick now copies activeItems before mutating so store state is never dirtied in place and array-reference selectors observe changes. AsyncQueuer and AsyncRateLimiter keep isExecuting true until every overlapping execution settles (derived from the live retryer count) instead of clearing it when the first one finishes. The flushAsBatch mid-batch test now pins the queue via pendingTick so it genuinely exercises the finally-restart. Throttled callback docstrings now describe the actual promise resolution: only the most recent call gets the trailing result; earlier calls resolve with the previous result. Co-Authored-By: Claude Fable 5 --- .../functions/useAsyncThrottledCallback.md | 10 ++--- .../functions/useAsyncThrottledCallback.md | 10 ++--- docs/reference/classes/AsyncQueuer.md | 24 +++++------ docs/reference/classes/AsyncRateLimiter.md | 10 ++--- docs/reference/functions/asyncQueue.md | 2 +- docs/reference/functions/asyncRateLimit.md | 2 +- packages/pacer/src/async-queuer.ts | 7 ++-- packages/pacer/src/async-rate-limiter.ts | 3 +- packages/pacer/tests/async-queuer.test.ts | 41 ++++++++++++++++--- .../pacer/tests/async-rate-limiter.test.ts | 35 ++++++++++++++++ packages/pacer/tests/async-throttler.test.ts | 13 +++++- .../useAsyncThrottledCallback.ts | 10 ++--- .../useAsyncThrottledCallback.ts | 10 ++--- 13 files changed, 128 insertions(+), 49 deletions(-) diff --git a/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md b/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md index dd6b56924..dc7217598 100644 --- a/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md +++ b/docs/framework/preact/reference/functions/useAsyncThrottledCallback.md @@ -16,11 +16,11 @@ This hook is a convenient wrapper around the `useAsyncThrottler` hook, providing a stable, throttled async function reference for use in Preact components. The throttled async function will execute at most once within the specified wait time period, -regardless of how many times it is called. Calls made during the wait period can schedule a -single trailing execution with the latest arguments when `trailing` is enabled (the default), -and those calls share the trailing execution's result. The returned function always returns -a promise that resolves or rejects with the result of the original async function, and -resolves with `undefined` when the throttler is disabled. +regardless of how many times it is called. Calls made during the wait period reschedule a +single trailing execution with the latest arguments when `trailing` is enabled (the default). +The most recent call's promise resolves or rejects with the trailing execution's result; +each earlier call's promise resolves immediately with the most recent previous result (or +`undefined` if nothing has executed yet), as does every call when the throttler is disabled. This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic async throttling needs. However, it does not expose the underlying AsyncThrottler instance. diff --git a/docs/framework/react/reference/functions/useAsyncThrottledCallback.md b/docs/framework/react/reference/functions/useAsyncThrottledCallback.md index ddcba5626..2779abfe7 100644 --- a/docs/framework/react/reference/functions/useAsyncThrottledCallback.md +++ b/docs/framework/react/reference/functions/useAsyncThrottledCallback.md @@ -16,11 +16,11 @@ This hook is a convenient wrapper around the `useAsyncThrottler` hook, providing a stable, throttled async function reference for use in React components. The throttled async function will execute at most once within the specified wait time period, -regardless of how many times it is called. Calls made during the wait period can schedule a -single trailing execution with the latest arguments when `trailing` is enabled (the default), -and those calls share the trailing execution's result. The returned function always returns -a promise that resolves or rejects with the result of the original async function, and -resolves with `undefined` when the throttler is disabled. +regardless of how many times it is called. Calls made during the wait period reschedule a +single trailing execution with the latest arguments when `trailing` is enabled (the default). +The most recent call's promise resolves or rejects with the trailing execution's result; +each earlier call's promise resolves immediately with the most recent previous result (or +`undefined` if nothing has executed yet), as does every call when the throttler is disabled. This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic async throttling needs. However, it does not expose the underlying AsyncThrottler instance. diff --git a/docs/reference/classes/AsyncQueuer.md b/docs/reference/classes/AsyncQueuer.md index 178d1aa76..56ed32dac 100644 --- a/docs/reference/classes/AsyncQueuer.md +++ b/docs/reference/classes/AsyncQueuer.md @@ -168,7 +168,7 @@ Defined in: [async-queuer.ts:316](https://github.com/TanStack/pacer/blob/main/pa abort(): void; ``` -Defined in: [async-queuer.ts:898](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L898) +Defined in: [async-queuer.ts:899](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L899) Aborts all ongoing executions with the internal abort controllers. Does NOT clear out the items. @@ -227,7 +227,7 @@ queuer.addItem('task2', 'front'); clear(): void; ``` -Defined in: [async-queuer.ts:863](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L863) +Defined in: [async-queuer.ts:864](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L864) Removes all pending items from the queue. Does NOT affect active tasks. @@ -274,7 +274,7 @@ queuer.execute('back'); flush(numberOfItems, position?): Promise; ``` -Defined in: [async-queuer.ts:688](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L688) +Defined in: [async-queuer.ts:689](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L689) Processes a specified number of items to execute immediately with no wait time If no numberOfItems is provided, all items will be processed @@ -301,7 +301,7 @@ If no numberOfItems is provided, all items will be processed flushAsBatch(batchFunction): Promise; ``` -Defined in: [async-queuer.ts:725](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L725) +Defined in: [async-queuer.ts:726](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L726) Processes all items in the queue as a batch using the provided function as an argument The queue is cleared after processing @@ -324,7 +324,7 @@ The queue is cleared after processing getAbortSignal(executeCount?): AbortSignal | null; ``` -Defined in: [async-queuer.ts:888](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L888) +Defined in: [async-queuer.ts:889](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L889) Returns the AbortSignal for a specific execution. If no executeCount is provided, returns the signal for the most recent execution. @@ -397,7 +397,7 @@ queuer.getNextItem('back'); peekActiveItems(): TValue[]; ``` -Defined in: [async-queuer.ts:825](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L825) +Defined in: [async-queuer.ts:826](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L826) Returns the items currently being processed (active tasks). @@ -413,7 +413,7 @@ Returns the items currently being processed (active tasks). peekAllItems(): TValue[]; ``` -Defined in: [async-queuer.ts:818](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L818) +Defined in: [async-queuer.ts:819](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L819) Returns a copy of all items in the queue, including active and pending items. @@ -429,7 +429,7 @@ Returns a copy of all items in the queue, including active and pending items. peekNextItem(position): TValue | undefined; ``` -Defined in: [async-queuer.ts:808](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L808) +Defined in: [async-queuer.ts:809](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L809) Returns the next item in the queue without removing it. @@ -458,7 +458,7 @@ queuer.peekNextItem('back'); // back peekPendingItems(): TValue[]; ``` -Defined in: [async-queuer.ts:832](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L832) +Defined in: [async-queuer.ts:833](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L833) Returns the items waiting to be processed (pending tasks). @@ -474,7 +474,7 @@ Returns the items waiting to be processed (pending tasks). reset(): void; ``` -Defined in: [async-queuer.ts:909](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L909) +Defined in: [async-queuer.ts:910](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L910) Resets the queuer state to its default values @@ -512,7 +512,7 @@ Updates the queuer options. New options are merged with existing options. start(): void; ``` -Defined in: [async-queuer.ts:839](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L839) +Defined in: [async-queuer.ts:840](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L840) Starts processing items in the queue. If already running, does nothing. @@ -528,7 +528,7 @@ Starts processing items in the queue. If already running, does nothing. stop(): void; ``` -Defined in: [async-queuer.ts:849](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L849) +Defined in: [async-queuer.ts:850](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L850) Stops processing items in the queue. Does not clear the queue. diff --git a/docs/reference/classes/AsyncRateLimiter.md b/docs/reference/classes/AsyncRateLimiter.md index 33ae88880..eda2d3ca5 100644 --- a/docs/reference/classes/AsyncRateLimiter.md +++ b/docs/reference/classes/AsyncRateLimiter.md @@ -169,7 +169,7 @@ Defined in: [async-rate-limiter.ts:246](https://github.com/TanStack/pacer/blob/m abort(): void; ``` -Defined in: [async-rate-limiter.ts:539](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L539) +Defined in: [async-rate-limiter.ts:540](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L540) Aborts all ongoing executions with the internal abort controllers. Does NOT clear out the execution times or reset the rate limiter. @@ -186,7 +186,7 @@ Does NOT clear out the execution times or reset the rate limiter. getAbortSignal(maybeExecuteCount?): AbortSignal | null; ``` -Defined in: [async-rate-limiter.ts:529](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L529) +Defined in: [async-rate-limiter.ts:530](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L530) Returns the AbortSignal for a specific execution. If no maybeExecuteCount is provided, returns the signal for the most recent execution. @@ -227,7 +227,7 @@ const rateLimiter = new AsyncRateLimiter( getMsUntilNextWindow(): number; ``` -Defined in: [async-rate-limiter.ts:501](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L501) +Defined in: [async-rate-limiter.ts:502](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L502) Returns the number of milliseconds until the next execution will be possible For fixed windows, this is the time until the current window resets @@ -245,7 +245,7 @@ For sliding windows, this is the time until the oldest execution expires getRemainingInWindow(): number; ``` -Defined in: [async-rate-limiter.ts:491](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L491) +Defined in: [async-rate-limiter.ts:492](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L492) Returns the number of remaining executions allowed in the current window @@ -309,7 +309,7 @@ const result2 = await rateLimiter.maybeExecute('arg1', 'arg2'); // undefined reset(): void; ``` -Defined in: [async-rate-limiter.ts:550](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L550) +Defined in: [async-rate-limiter.ts:551](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L551) Resets the rate limiter state diff --git a/docs/reference/functions/asyncQueue.md b/docs/reference/functions/asyncQueue.md index 61fb4e1b8..8c189beea 100644 --- a/docs/reference/functions/asyncQueue.md +++ b/docs/reference/functions/asyncQueue.md @@ -9,7 +9,7 @@ title: asyncQueue function asyncQueue(fn, initialOptions): (item, position, runOnItemsChange) => boolean; ``` -Defined in: [async-queuer.ts:981](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L981) +Defined in: [async-queuer.ts:982](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-queuer.ts#L982) Creates a new AsyncQueuer instance and returns a bound addItem function for adding tasks. The queuer is started automatically and ready to process items. diff --git a/docs/reference/functions/asyncRateLimit.md b/docs/reference/functions/asyncRateLimit.md index f3cd083b2..c679bd9a6 100644 --- a/docs/reference/functions/asyncRateLimit.md +++ b/docs/reference/functions/asyncRateLimit.md @@ -9,7 +9,7 @@ title: asyncRateLimit function asyncRateLimit(fn, initialOptions): (...args) => Promise> | undefined>; ``` -Defined in: [async-rate-limiter.ts:641](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L641) +Defined in: [async-rate-limiter.ts:642](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-rate-limiter.ts#L642) Creates an async rate-limited function that will execute the provided function up to a maximum number of times within a time window. diff --git a/packages/pacer/src/async-queuer.ts b/packages/pacer/src/async-queuer.ts index a6510421a..3fd82f669 100644 --- a/packages/pacer/src/async-queuer.ts +++ b/packages/pacer/src/async-queuer.ts @@ -432,7 +432,7 @@ export class AsyncQueuer { // Process items concurrently up to the concurrency limit let scheduledAsyncWork = false - const activeItems = this.store.state.activeItems + const activeItems = [...this.store.state.activeItems] while ( activeItems.length < this.#getConcurrency() && this.store.state.items.length > 0 @@ -443,7 +443,7 @@ export class AsyncQueuer { } activeItems.push(nextItem) this.#setState({ - activeItems, + activeItems: [...activeItems], }) scheduledAsyncWork = true ;(async () => { @@ -672,7 +672,8 @@ export class AsyncQueuer { } this.#setState({ activeItems: remainingActiveItems, - isExecuting: false, + // other executions may still be in flight (concurrency > 1 or flush) + isExecuting: this.asyncRetryers.size > 0, settledCount: this.store.state.settledCount + 1, }) this.options.onSettled?.(item, this) diff --git a/packages/pacer/src/async-rate-limiter.ts b/packages/pacer/src/async-rate-limiter.ts index 7c8564131..ee0d394c0 100644 --- a/packages/pacer/src/async-rate-limiter.ts +++ b/packages/pacer/src/async-rate-limiter.ts @@ -416,7 +416,8 @@ export class AsyncRateLimiter { } finally { this.asyncRetryers.delete(currentMaybeExecute) // dispose retryer this.#setState({ - isExecuting: false, + // other executions may still be in flight within the window + isExecuting: this.asyncRetryers.size > 0, settleCount: this.store.state.settleCount + 1, }) this.options.onSettled?.(args, this) diff --git a/packages/pacer/tests/async-queuer.test.ts b/packages/pacer/tests/async-queuer.test.ts index a182e8c0b..edc752139 100644 --- a/packages/pacer/tests/async-queuer.test.ts +++ b/packages/pacer/tests/async-queuer.test.ts @@ -1475,25 +1475,29 @@ describe('AsyncQueuer', () => { results.push(item) return item }, - { started: false }, + { wait: 100, started: false }, ) asyncQueuer.addItem('a') asyncQueuer.addItem('b') + asyncQueuer.start() + await vi.advanceTimersByTimeAsync(0) // 'a' processed, wait timer pending, pendingTick true const batchPromise = asyncQueuer.flushAsBatch(async (items) => { batches.push(items) await new Promise((resolve) => setTimeout(resolve, 50)) }) + // pendingTick is still true, so this addItem cannot start a tick itself — + // only the finally-restart after the batch settles can process it asyncQueuer.addItem('late') - asyncQueuer.start() + expect(results).toEqual(['a']) await vi.advanceTimersByTimeAsync(50) await batchPromise - await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(0) - expect(batches).toEqual([['a', 'b']]) - expect(results).toEqual(['late']) + expect(batches).toEqual([['b']]) + expect(results).toEqual(['a', 'late']) }) it('should not restart processing when flushing a stopped queuer', async () => { @@ -1591,6 +1595,33 @@ describe('AsyncQueuer', () => { expect(asyncQueuer.peekActiveItems()).toEqual([]) }) + it('should keep isExecuting true until all concurrent executions settle', async () => { + const resolvers: Array<() => void> = [] + const asyncQueuer = new AsyncQueuer( + (item) => { + return new Promise((resolve) => { + resolvers.push(() => resolve(item)) + }) + }, + { concurrency: 2, started: false }, + ) + + asyncQueuer.addItem(1) + asyncQueuer.addItem(2) + asyncQueuer.start() + + await vi.advanceTimersByTimeAsync(0) + expect(asyncQueuer.store.state.isExecuting).toBe(true) + + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + expect(asyncQueuer.store.state.isExecuting).toBe(true) // second still in flight + + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + expect(asyncQueuer.store.state.isExecuting).toBe(false) + }) + it('should process duplicate falsy items with concurrency > 1', async () => { const processed: Array = [] const asyncQueuer = new AsyncQueuer( diff --git a/packages/pacer/tests/async-rate-limiter.test.ts b/packages/pacer/tests/async-rate-limiter.test.ts index 17d4b8c27..7159effa1 100644 --- a/packages/pacer/tests/async-rate-limiter.test.ts +++ b/packages/pacer/tests/async-rate-limiter.test.ts @@ -817,6 +817,41 @@ describe('AsyncRateLimiter internal retryer devtools registration', () => { }) }) +describe('AsyncRateLimiter concurrent execution state', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('should keep isExecuting true until all overlapping executions settle', async () => { + const resolvers: Array<() => void> = [] + const rateLimiter = new AsyncRateLimiter( + (value: string) => { + return new Promise((resolve) => { + resolvers.push(() => resolve(value)) + }) + }, + { limit: 5, window: 1000 }, + ) + + const p1 = rateLimiter.maybeExecute('one') + const p2 = rateLimiter.maybeExecute('two') + expect(rateLimiter.store.state.isExecuting).toBe(true) + + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + expect(rateLimiter.store.state.isExecuting).toBe(true) // second still in flight + + resolvers.shift()!() + await vi.advanceTimersByTimeAsync(0) + expect(rateLimiter.store.state.isExecuting).toBe(false) + + await Promise.all([p1, p2]) + }) +}) + describe('AsyncRateLimiter return type inference', () => { it('should resolve to the awaited return type, not a nested promise', () => { const rateLimiter = new AsyncRateLimiter(async (value: string) => value, { diff --git a/packages/pacer/tests/async-throttler.test.ts b/packages/pacer/tests/async-throttler.test.ts index 1cba6dad6..62c13dbde 100644 --- a/packages/pacer/tests/async-throttler.test.ts +++ b/packages/pacer/tests/async-throttler.test.ts @@ -1,4 +1,12 @@ -import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from 'vitest' import { AsyncThrottler } from '../src/async-throttler' import { getPacerDevtoolsInstance } from '../src' @@ -1005,6 +1013,9 @@ describe('AsyncThrottler internal retryer devtools registration', () => { beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { + vi.useRealTimers() + }) it('should not register internal retryers with or without a key', async () => { const throttler = new AsyncThrottler(async (value: string) => value, { diff --git a/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts b/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts index 1054c7960..5aa821a38 100644 --- a/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts +++ b/packages/preact-pacer/src/async-throttler/useAsyncThrottledCallback.ts @@ -9,11 +9,11 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * providing a stable, throttled async function reference for use in Preact components. * * The throttled async function will execute at most once within the specified wait time period, - * regardless of how many times it is called. Calls made during the wait period can schedule a - * single trailing execution with the latest arguments when `trailing` is enabled (the default), - * and those calls share the trailing execution's result. The returned function always returns - * a promise that resolves or rejects with the result of the original async function, and - * resolves with `undefined` when the throttler is disabled. + * regardless of how many times it is called. Calls made during the wait period reschedule a + * single trailing execution with the latest arguments when `trailing` is enabled (the default). + * The most recent call's promise resolves or rejects with the trailing execution's result; + * each earlier call's promise resolves immediately with the most recent previous result (or + * `undefined` if nothing has executed yet), as does every call when the throttler is disabled. * * This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic * async throttling needs. However, it does not expose the underlying AsyncThrottler instance. diff --git a/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts b/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts index ee95367e9..203c14015 100644 --- a/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts +++ b/packages/react-pacer/src/async-throttler/useAsyncThrottledCallback.ts @@ -9,11 +9,11 @@ import type { AnyAsyncFunction } from '@tanstack/pacer/types' * providing a stable, throttled async function reference for use in React components. * * The throttled async function will execute at most once within the specified wait time period, - * regardless of how many times it is called. Calls made during the wait period can schedule a - * single trailing execution with the latest arguments when `trailing` is enabled (the default), - * and those calls share the trailing execution's result. The returned function always returns - * a promise that resolves or rejects with the result of the original async function, and - * resolves with `undefined` when the throttler is disabled. + * regardless of how many times it is called. Calls made during the wait period reschedule a + * single trailing execution with the latest arguments when `trailing` is enabled (the default). + * The most recent call's promise resolves or rejects with the trailing execution's result; + * each earlier call's promise resolves immediately with the most recent previous result (or + * `undefined` if nothing has executed yet), as does every call when the throttler is disabled. * * This hook provides a simpler API compared to `useAsyncThrottler`, making it ideal for basic * async throttling needs. However, it does not expose the underlying AsyncThrottler instance.