From 1f8a366e816298efb231a629e8070f687c0ea593 Mon Sep 17 00:00:00 2001 From: Grant Forrest Date: Thu, 3 Sep 2026 14:50:04 -0400 Subject: [PATCH] fix: preserve lastArgs of queued execution (#257) clear lastArgs upon execution, not after, to avoid resetting lastArgs of upcoming queued execution after current completes --- .changeset/little-planets-serve.md | 5 +++ packages/pacer/src/async-debouncer.ts | 3 +- packages/pacer/tests/async-debouncer.test.ts | 33 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 .changeset/little-planets-serve.md diff --git a/.changeset/little-planets-serve.md b/.changeset/little-planets-serve.md new file mode 100644 index 000000000..b445640d2 --- /dev/null +++ b/.changeset/little-planets-serve.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': patch +--- + +Fixed a bug in AsyncDebouncer where a scheduled execution is dropped if an in-flight execution completes before it is invoked diff --git a/packages/pacer/src/async-debouncer.ts b/packages/pacer/src/async-debouncer.ts index 5a6a0b657..fcd1a0089 100644 --- a/packages/pacer/src/async-debouncer.ts +++ b/packages/pacer/src/async-debouncer.ts @@ -366,7 +366,7 @@ export class AsyncDebouncer { const currentMaybeExecuteCount = this.store.state.maybeExecuteCount + 1 try { - this.#setState({ isExecuting: true }) + this.#setState({ isExecuting: true, lastArgs: undefined }) const currentAsyncRetryer = new AsyncRetryer( this.fn, this.options.asyncRetryerOptions, @@ -391,7 +391,6 @@ export class AsyncDebouncer { this.#setState({ isExecuting: false, isPending: false, - lastArgs: undefined, settleCount: this.store.state.settleCount + 1, }) this.options.onSettled?.(args, this) diff --git a/packages/pacer/tests/async-debouncer.test.ts b/packages/pacer/tests/async-debouncer.test.ts index 393d6158c..9ee9415ce 100644 --- a/packages/pacer/tests/async-debouncer.test.ts +++ b/packages/pacer/tests/async-debouncer.test.ts @@ -237,6 +237,39 @@ describe('AsyncDebouncer', () => { expect(mockFn).toHaveBeenLastCalledWith('fifth') await promise5 }) + + it('should execute a trailing call scheduled after a prior execution', async () => { + const mockFn = vi.fn( + (_: string) => + new Promise((resolve) => setTimeout(() => resolve('result'), 500)), + ) + const debouncer = new AsyncDebouncer(mockFn, { + wait: 1000, + leading: true, + trailing: true, + }) + + // First call - should execute immediately + const promise1 = debouncer.maybeExecute('first') + expect(mockFn).toBeCalledTimes(1) + expect(mockFn).toBeCalledWith('first') + + // Second call enqueued during first execution + const promise2 = debouncer.maybeExecute('second') + expect(mockFn).toBeCalledTimes(1) + + // Advance past first execution + vi.advanceTimersByTime(500) + await promise1 + + // Trigger second execution + vi.advanceTimersByTime(1000) + await promise2 + + // Verify trailing execution occurred + expect(mockFn).toBeCalledTimes(2) + expect(mockFn).toHaveBeenLastCalledWith('second') + }) }) describe('Promise Handling', () => {