Skip to content

Commit e892695

Browse files
authored
Merge pull request #38 from vim-fall/performance-imp
perf: optimize picker performance with event queue and preview debouncing
2 parents e435174 + 3bb15e4 commit e892695

File tree

9 files changed

+312
-45
lines changed

9 files changed

+312
-45
lines changed

deno.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"exclude": [
33
"docs/**",
4-
".coverage/**"
4+
".coverage/**",
5+
".worktrees/**"
56
],
67
"tasks": {
78
"check": "deno check ./**/*.ts",

denops/fall/component/input.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class InputComponent extends BaseComponent {
7979
#modifiedWindow = true;
8080
#modifiedContent = true;
8181

82+
// Cache for byte lengths to avoid repeated calculations
83+
#prefixCache?: { value: string; byteLength: number };
84+
#suffixCache?: { value: string; byteLength: number };
85+
8286
constructor(
8387
{
8488
title,
@@ -287,9 +291,25 @@ export class InputComponent extends BaseComponent {
287291
this.#offset,
288292
this.#offset + cmdwidth,
289293
);
290-
const prefixByteLength = getByteLength(prefix);
294+
295+
// Use cached byte lengths when possible
296+
let prefixByteLength: number;
297+
if (this.#prefixCache?.value === prefix) {
298+
prefixByteLength = this.#prefixCache.byteLength;
299+
} else {
300+
prefixByteLength = getByteLength(prefix);
301+
this.#prefixCache = { value: prefix, byteLength: prefixByteLength };
302+
}
303+
291304
const middleByteLength = getByteLength(middle);
292-
const suffixByteLength = getByteLength(suffix);
305+
306+
let suffixByteLength: number;
307+
if (this.#suffixCache?.value === suffix) {
308+
suffixByteLength = this.#suffixCache.byteLength;
309+
} else {
310+
suffixByteLength = getByteLength(suffix);
311+
this.#suffixCache = { value: suffix, byteLength: suffixByteLength };
312+
}
293313

294314
await buffer.replace(denops, bufnr, [prefix + middle + suffix]);
295315
signal?.throwIfAborted();

denops/fall/event.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ export function dispatch(event: Readonly<Event>): void {
77
}
88

99
export function consume(consumer: Consumer): void {
10+
// Optimize: Swap arrays instead of creating new ones each time
1011
const events = eventQueue;
12+
if (events.length === 0) return;
13+
1114
eventQueue = [];
12-
events.forEach(consumer);
15+
// Use for loop instead of forEach for better performance
16+
for (let i = 0; i < events.length; i++) {
17+
consumer(events[i]);
18+
}
1319
}
1420

1521
type SelectMethod = "on" | "off" | "toggle";

denops/fall/event_test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,80 @@ Deno.test("Event", async (t) => {
2323
});
2424
assertEquals(dispatchedEvents, []);
2525
});
26+
27+
await t.step("multiple consumers receive all events in order", () => {
28+
dispatch({ type: "vim-cmdline-changed", cmdline: "test1" });
29+
dispatch({ type: "vim-cmdpos-changed", cmdpos: 5 });
30+
dispatch({ type: "vim-cmdline-changed", cmdline: "test2" });
31+
32+
const results: Event[][] = [];
33+
consume((event) => {
34+
if (!results[0]) results[0] = [];
35+
results[0].push(event);
36+
});
37+
38+
assertEquals(results[0], [
39+
{ type: "vim-cmdline-changed", cmdline: "test1" },
40+
{ type: "vim-cmdpos-changed", cmdpos: 5 },
41+
{ type: "vim-cmdline-changed", cmdline: "test2" },
42+
]);
43+
});
44+
45+
await t.step("handles large number of events", () => {
46+
const eventCount = 10000;
47+
for (let i = 0; i < eventCount; i++) {
48+
dispatch({ type: "vim-cmdpos-changed", cmdpos: i });
49+
}
50+
51+
let receivedCount = 0;
52+
consume((event) => {
53+
assertEquals(event.type, "vim-cmdpos-changed");
54+
receivedCount++;
55+
});
56+
57+
assertEquals(receivedCount, eventCount);
58+
});
59+
60+
await t.step("events are cleared after consume", () => {
61+
dispatch({ type: "vim-cmdline-changed", cmdline: "test" });
62+
63+
let firstConsumeCount = 0;
64+
consume(() => {
65+
firstConsumeCount++;
66+
});
67+
assertEquals(firstConsumeCount, 1);
68+
69+
let secondConsumeCount = 0;
70+
consume(() => {
71+
secondConsumeCount++;
72+
});
73+
assertEquals(secondConsumeCount, 0);
74+
});
75+
76+
await t.step("handles events dispatched during consume", () => {
77+
dispatch({ type: "vim-cmdline-changed", cmdline: "initial" });
78+
79+
const events: Event[] = [];
80+
consume((event) => {
81+
events.push(event);
82+
if (event.type === "vim-cmdline-changed" && event.cmdline === "initial") {
83+
// This dispatch happens during consume - should not be consumed in this cycle
84+
dispatch({ type: "vim-cmdpos-changed", cmdpos: 42 });
85+
}
86+
});
87+
88+
assertEquals(events, [
89+
{ type: "vim-cmdline-changed", cmdline: "initial" },
90+
]);
91+
92+
// The event dispatched during consume should be available in next consume
93+
const nextEvents: Event[] = [];
94+
consume((event) => {
95+
nextEvents.push(event);
96+
});
97+
98+
assertEquals(nextEvents, [
99+
{ type: "vim-cmdpos-changed", cmdpos: 42 },
100+
]);
101+
});
26102
});

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+
});

0 commit comments

Comments
 (0)