Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion Dechat/dex_with_fiat_frontend/src/lib/chatSearch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import {
debounce,
findHighlights,
splitByHighlights,
searchChatHistory,
Expand Down Expand Up @@ -287,3 +288,77 @@ describe('searchChatHistory – date range', () => {
expect(result.matches[0].highlights).toHaveLength(0);
});
});

// ---------------------------------------------------------------------------
// debounce — stale-closure regression tests (#1225)
// ---------------------------------------------------------------------------

describe('debounce', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

it('calls fn after the delay', () => {
const fn = vi.fn();
const d = debounce(fn, 200);
d('a');
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('a');
});

it('resets the timer on rapid successive calls (debounce behaviour)', () => {
const fn = vi.fn();
const d = debounce(fn, 300);
d('first');
vi.advanceTimersByTime(100);
d('second');
vi.advanceTimersByTime(100);
d('third');
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('third');
});

it('regression: calls the latest fn after updateFn (stale-closure fix)', () => {
// Bug: before fix, `fn` was captured at debounce() call time and never
// updated, so a re-rendered callback was never reflected.
const originalFn = vi.fn();
const updatedFn = vi.fn();
const d = debounce(originalFn, 100);
d.updateFn(updatedFn); // simulate caller updating fn after re-render
d('payload');
vi.advanceTimersByTime(100);
expect(originalFn).not.toHaveBeenCalled();
expect(updatedFn).toHaveBeenCalledWith('payload');
});

it('cancel() prevents a pending callback from firing', () => {
const fn = vi.fn();
const d = debounce(fn, 200);
d('x');
d.cancel(); // e.g. called in useEffect cleanup on component unmount
vi.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled();
});

it('resets timer reference to null after firing (no stale timer ID)', () => {
const fn = vi.fn();
const d = debounce(fn, 100);
d('first');
vi.advanceTimersByTime(100); // timer fires, should be null now
// cancel on an already-fired timer must be a no-op, not throw
expect(() => d.cancel()).not.toThrow();
});

it('allows a second call after cancel without the first leaking', () => {
const fn = vi.fn();
const d = debounce(fn, 150);
d('a');
d.cancel();
d('b');
vi.advanceTimersByTime(150);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('b');
});
});
37 changes: 33 additions & 4 deletions Dechat/dex_with_fiat_frontend/src/lib/chatSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,45 @@ export interface SearchResults {
totalMessages: number;
}

export interface DebouncedFn<T extends (...args: unknown[]) => void> {
(...args: Parameters<T>): void;
/** Cancel any pending invocation. Call on component unmount to avoid stale callbacks. */
cancel(): void;
/** Swap the underlying function without resetting the timer. Prevents stale-closure bugs
* when the caller's callback is recreated on each render (e.g. an inline arrow function). */
updateFn(newFn: T): void;
}

/** Debounce helper — returns a debounced version of `fn`. */
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delayMs: number,
): (...args: Parameters<T>) => void {
): DebouncedFn<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delayMs);
// Store the latest fn so callers can update it via updateFn() without
// recreating the debounced wrapper (fixes stale-closure on re-render).
let latestFn: T = fn;

function debounced(...args: Parameters<T>): void {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null; // reset so future clearTimeout checks are accurate
latestFn(...args);
}, delayMs);
}

debounced.cancel = (): void => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};

debounced.updateFn = (newFn: T): void => {
latestFn = newFn;
};

return debounced;
}

/**
Expand Down
Loading