Skip to content

Commit 6704a15

Browse files
lambdalisueclaude
andcommitted
perf: add debounce for preview updates to reduce unnecessary renders
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 3ee27a6 commit 6704a15

File tree

3 files changed

+173
-5
lines changed

3 files changed

+173
-5
lines changed

denops/fall/lib/debounce.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
export type DebounceOptions = {
2+
delay?: number;
3+
signal?: AbortSignal;
4+
};
5+
6+
/**
7+
* Creates a debounced function that delays invoking the provided function until after
8+
* the specified delay has elapsed since the last time the debounced function was invoked.
9+
*
10+
* @param fn - The function to debounce
11+
* @param options - Configuration options
12+
* @param options.delay - The number of milliseconds to delay (default: 0)
13+
* @param options.signal - An optional AbortSignal to cancel the debounced function
14+
* @returns A debounced version of the function
15+
*
16+
* @example
17+
* ```ts
18+
* import { debounce } from "./debounce.ts";
19+
* import { delay } from "jsr:@std/async@^1.0.0/delay";
20+
*
21+
* const saveData = () => console.log("Saving data...");
22+
* const debouncedSave = debounce(() => saveData(), { delay: 100 });
23+
*
24+
* // Multiple calls within 100ms will only trigger one save
25+
* debouncedSave();
26+
* debouncedSave();
27+
* debouncedSave();
28+
*
29+
* // Wait for the debounced function to execute
30+
* await delay(150);
31+
*
32+
* // Cancel via AbortSignal
33+
* const doWork = () => console.log("Doing work...");
34+
* const controller = new AbortController();
35+
* const debouncedFunc = debounce(() => doWork(), {
36+
* delay: 50,
37+
* signal: controller.signal
38+
* });
39+
* debouncedFunc();
40+
* controller.abort(); // Cancels any pending execution
41+
* ```
42+
*/
43+
// deno-lint-ignore no-explicit-any
44+
export function debounce<F extends (...args: any[]) => void>(
45+
fn: F,
46+
{ delay, signal }: DebounceOptions = {},
47+
): F {
48+
let timerId: number | undefined;
49+
50+
const abort = () => {
51+
if (timerId !== undefined) {
52+
clearTimeout(timerId);
53+
timerId = undefined;
54+
}
55+
};
56+
57+
signal?.addEventListener("abort", abort, { once: true });
58+
return ((...args) => {
59+
abort();
60+
timerId = setTimeout(() => {
61+
timerId = undefined;
62+
fn(...args);
63+
}, delay);
64+
}) as F;
65+
}

denops/fall/lib/debounce_test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { assertEquals } from "jsr:@std/assert@^1.0.6";
2+
import { delay } from "jsr:@std/async@^1.0.0/delay";
3+
import { FakeTime } from "jsr:@std/testing@^1.0.0/time";
4+
5+
import { debounce } from "./debounce.ts";
6+
7+
Deno.test("debounce", async (t) => {
8+
await t.step("delays function execution", async () => {
9+
using time = new FakeTime();
10+
11+
let callCount = 0;
12+
const fn = debounce(() => {
13+
callCount++;
14+
}, { delay: 100 });
15+
16+
fn();
17+
assertEquals(callCount, 0);
18+
19+
await time.tickAsync(50);
20+
assertEquals(callCount, 0);
21+
22+
await time.tickAsync(50);
23+
assertEquals(callCount, 1);
24+
});
25+
26+
await t.step(
27+
"cancels previous calls when called multiple times",
28+
async () => {
29+
using time = new FakeTime();
30+
31+
let callCount = 0;
32+
let lastValue = 0;
33+
const fn = debounce((value: number) => {
34+
callCount++;
35+
lastValue = value;
36+
}, { delay: 100 });
37+
38+
fn(1);
39+
await time.tickAsync(50);
40+
fn(2);
41+
await time.tickAsync(50);
42+
fn(3);
43+
await time.tickAsync(50);
44+
45+
assertEquals(callCount, 0);
46+
47+
await time.tickAsync(50);
48+
assertEquals(callCount, 1);
49+
assertEquals(lastValue, 3);
50+
},
51+
);
52+
53+
await t.step("works with real timers", async () => {
54+
let callCount = 0;
55+
const fn = debounce(() => {
56+
callCount++;
57+
}, { delay: 50 });
58+
59+
fn();
60+
assertEquals(callCount, 0);
61+
62+
await delay(30);
63+
assertEquals(callCount, 0);
64+
65+
await delay(30);
66+
assertEquals(callCount, 1);
67+
});
68+
69+
await t.step("respects abort signal", async () => {
70+
using time = new FakeTime();
71+
72+
let callCount = 0;
73+
const controller = new AbortController();
74+
const fn = debounce(() => {
75+
callCount++;
76+
}, { delay: 100, signal: controller.signal });
77+
78+
fn();
79+
await time.tickAsync(50);
80+
81+
controller.abort();
82+
83+
await time.tickAsync(100);
84+
assertEquals(callCount, 0);
85+
});
86+
});

denops/fall/picker.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { Previewer } from "jsr:@vim-fall/core@^0.3.0/previewer";
1818
import type { Theme } from "jsr:@vim-fall/core@^0.3.0/theme";
1919

2020
import { Scheduler } from "./lib/scheduler.ts";
21+
import { debounce } from "./lib/debounce.ts";
2122
import { Cmdliner } from "./util/cmdliner.ts";
2223
import { isIncrementalMatcher } from "./util/predicate.ts";
2324
import { buildMappingHelpPages } from "./util/mapping.ts";
@@ -37,6 +38,7 @@ import { HelpComponent } from "./component/help.ts";
3738
import { consume, type Event } from "./event.ts";
3839

3940
const SCHEDULER_INTERVAL = 10;
41+
const PREVIEW_DEBOUNCE_DELAY = 150;
4042
const MATCHER_ICON = "🅼 ";
4143
const SORTER_ICON = "🆂 ";
4244
const RENDERER_ICON = "🆁 ";
@@ -70,6 +72,7 @@ export type PickerResult<T extends Detail> = {
7072

7173
export type PickerOptions = {
7274
schedulerInterval?: number;
75+
previewDebounceDelay?: number;
7376
};
7477

7578
export type PickerContext<T extends Detail> = {
@@ -89,6 +92,7 @@ export class Picker<T extends Detail> implements AsyncDisposable {
8992
static readonly ZINDEX_ALLOCATION = 4;
9093
readonly #stack = new AsyncDisposableStack();
9194
readonly #schedulerInterval: number;
95+
readonly #previewDebounceDelay: number;
9296
readonly #name: string;
9397
readonly #coordinator: Coordinator;
9498
readonly #collectProcessor: CollectProcessor<T>;
@@ -110,6 +114,8 @@ export class Picker<T extends Detail> implements AsyncDisposable {
110114

111115
constructor(params: PickerParams<T>, options: PickerOptions = {}) {
112116
this.#schedulerInterval = options.schedulerInterval ?? SCHEDULER_INTERVAL;
117+
this.#previewDebounceDelay = options.previewDebounceDelay ??
118+
PREVIEW_DEBOUNCE_DELAY;
113119

114120
const { name, theme, coordinator, zindex = 50, context } = params;
115121
this.#name = name;
@@ -358,6 +364,10 @@ export class Picker<T extends Detail> implements AsyncDisposable {
358364
const reserve = (callback: ReservedCallback) => {
359365
reservedCallbacks.push(callback);
360366
};
367+
const reservePreviewDebounced = debounce(reserve, {
368+
delay: this.#previewDebounceDelay,
369+
signal,
370+
});
361371
const cmdliner = new Cmdliner({
362372
cmdline: this.#inputComponent.cmdline,
363373
cmdpos: this.#inputComponent.cmdpos,
@@ -368,7 +378,13 @@ export class Picker<T extends Detail> implements AsyncDisposable {
368378
await cmdliner.check(denops);
369379

370380
// Handle events synchronously
371-
consume((event) => this.#handleEvent(event, { accept, reserve }));
381+
consume((event) =>
382+
this.#handleEvent(event, {
383+
accept,
384+
reserve,
385+
reservePreviewDebounced,
386+
})
387+
);
372388

373389
// Handle reserved callbacks asynchronously
374390
for (const callback of reservedCallbacks) {
@@ -469,9 +485,10 @@ export class Picker<T extends Detail> implements AsyncDisposable {
469485
}
470486
}
471487

472-
#handleEvent(event: Event, { accept, reserve }: {
488+
#handleEvent(event: Event, { accept, reserve, reservePreviewDebounced }: {
473489
accept: (name: string) => Promise<void>;
474490
reserve: (callback: ReservedCallback) => void;
491+
reservePreviewDebounced: (callback: ReservedCallback) => void;
475492
}): void {
476493
switch (event.type) {
477494
case "vim-cmdline-changed":
@@ -617,7 +634,7 @@ export class Picker<T extends Detail> implements AsyncDisposable {
617634
}
618635
this.#previewProcessor.previewerIndex = index;
619636
this.#listComponent.title = this.#getExtensionIndicator();
620-
reserve((denops) => {
637+
reservePreviewDebounced((denops) => {
621638
this.#previewProcessor?.start(denops, {
622639
item: this.#matchProcessor.items[this.#renderProcessor.cursor],
623640
});
@@ -628,7 +645,7 @@ export class Picker<T extends Detail> implements AsyncDisposable {
628645
if (!this.#previewProcessor) break;
629646
this.#previewProcessor.previewerIndex = event.index;
630647
this.#listComponent.title = this.#getExtensionIndicator();
631-
reserve((denops) => {
648+
reservePreviewDebounced((denops) => {
632649
this.#previewProcessor?.start(denops, {
633650
item: this.#matchProcessor.items[this.#renderProcessor.cursor],
634651
});
@@ -756,7 +773,7 @@ export class Picker<T extends Detail> implements AsyncDisposable {
756773
const line = this.#renderProcessor.line;
757774
this.#listComponent.items = this.#renderProcessor.items;
758775
this.#listComponent.execute(`silent! normal! ${line}G`);
759-
reserve((denops) => {
776+
reservePreviewDebounced((denops) => {
760777
this.#previewProcessor?.start(denops, {
761778
item: this.#matchProcessor.items[this.#renderProcessor.cursor],
762779
});

0 commit comments

Comments
 (0)