From 88ddec213e2c9e69cc6d4108fec32d2747e59f12 Mon Sep 17 00:00:00 2001 From: ErfanMBE Date: Tue, 14 Jul 2026 14:55:13 +0330 Subject: [PATCH] fix: reset pending state after leading-only debouncer execution --- packages/pacer-lite/src/lite-debouncer.ts | 6 ++- .../pacer-lite/tests/lite-debouncer.test.ts | 17 ++++++++ packages/pacer/src/debouncer.ts | 6 ++- packages/pacer/tests/debouncer.test.ts | 39 +++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/pacer-lite/src/lite-debouncer.ts b/packages/pacer-lite/src/lite-debouncer.ts index 2c3adf79f..9bc2e9d95 100644 --- a/packages/pacer-lite/src/lite-debouncer.ts +++ b/packages/pacer-lite/src/lite-debouncer.ts @@ -99,7 +99,11 @@ export class LiteDebouncer { this.options.onExecute?.(args, this) } - this.lastArgs = args + // Only store args that were not already handled by the leading edge, + // so a later flush or trailing execution cannot re-execute them + if (!didLeadingExecute) { + this.lastArgs = args + } if (this.timeoutId) { clearTimeout(this.timeoutId) diff --git a/packages/pacer-lite/tests/lite-debouncer.test.ts b/packages/pacer-lite/tests/lite-debouncer.test.ts index e872772ae..85b38b0ba 100644 --- a/packages/pacer-lite/tests/lite-debouncer.test.ts +++ b/packages/pacer-lite/tests/lite-debouncer.test.ts @@ -275,6 +275,23 @@ describe('LiteDebouncer', () => { expect(mockFn).toHaveBeenLastCalledWith('second') }) + it('should not re-execute args already handled by the leading edge', () => { + const mockFn = vi.fn() + const debouncer = new LiteDebouncer(mockFn, { + wait: 1000, + leading: true, + trailing: true, + }) + + debouncer.maybeExecute('only-call') + expect(mockFn).toBeCalledTimes(1) + + // The single call was fully handled by the leading edge, so flush + // must not execute it a second time + debouncer.flush() + expect(mockFn).toBeCalledTimes(1) + }) + it('should flush pending execution even with trailing: false', () => { const mockFn = vi.fn() const debouncer = new LiteDebouncer(mockFn, { diff --git a/packages/pacer/src/debouncer.ts b/packages/pacer/src/debouncer.ts index 3073a3389..cf61250c5 100644 --- a/packages/pacer/src/debouncer.ts +++ b/packages/pacer/src/debouncer.ts @@ -232,8 +232,10 @@ export class Debouncer { this.#execute(...args) } - // Start pending state to indicate that the debouncer is waiting for the trailing edge - if (this.options.trailing) { + // Start pending state to indicate that the debouncer is waiting for the trailing edge. + // Skip if this call already executed on the leading edge, since the timeout + // will not perform a trailing execution for it. + if (this.options.trailing && !_didLeadingExecute) { this.#setState({ isPending: true, lastArgs: args }) } diff --git a/packages/pacer/tests/debouncer.test.ts b/packages/pacer/tests/debouncer.test.ts index b8d69ab3a..e39170e6c 100644 --- a/packages/pacer/tests/debouncer.test.ts +++ b/packages/pacer/tests/debouncer.test.ts @@ -261,6 +261,24 @@ describe('Debouncer', () => { expect(mockFn).toHaveBeenLastCalledWith('second') }) + it('should not re-execute stale args after a single leading execution', () => { + const mockFn = vi.fn() + const debouncer = new Debouncer(mockFn, { + wait: 1000, + leading: true, + trailing: true, + }) + + debouncer.maybeExecute('only-call') + expect(mockFn).toBeCalledTimes(1) + vi.advanceTimersByTime(1000) + + // The single call was fully handled by the leading edge, so there is + // nothing pending for flush to execute + debouncer.flush() + expect(mockFn).toBeCalledTimes(1) + }) + it('should not work with leading-only execution because there would be no trailing execution to flush', () => { const mockFn = vi.fn() const debouncer = new Debouncer(mockFn, { @@ -480,6 +498,27 @@ describe('Debouncer', () => { expect(debouncer.store.state.isPending).toBe(false) }) + it('should not be pending after a single leading-only execution', () => { + const mockFn = vi.fn() + const debouncer = new Debouncer(mockFn, { + wait: 1000, + leading: true, + trailing: true, + }) + + // A single call executes on the leading edge; no trailing execution + // is owed, so the debouncer should not report itself as pending + debouncer.maybeExecute('only-call') + expect(mockFn).toBeCalledTimes(1) + expect(debouncer.store.state.isPending).toBe(false) + expect(debouncer.store.state.lastArgs).toBeUndefined() + + vi.advanceTimersByTime(1000) + expect(mockFn).toBeCalledTimes(1) + expect(debouncer.store.state.isPending).toBe(false) + expect(debouncer.store.state.status).toBe('idle') + }) + it('should not be pending when leading and trailing are both false', () => { const mockFn = vi.fn() const debouncer = new Debouncer(mockFn, {