authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 13:25:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
logdd3babdcad749bd142a755fbc26c39085c85bc1c
tree93428fd6f911ccc41ca3cd1bf860ad36bc53c670
parent7aa717c5c21e74607b4f5e796491af417dc90ccf

Use LRU cache for keybinding when-expression AST parsing

- add reusable `LruCache` utility with bounded eviction - replace keybinding AST cache clear-all behavior with true LRU lookups - add unit tests for eviction, recency updates, and capacity validation

3 files changed, 81 insertions(+), 10 deletions(-)

apps/web/src/keybindings.ts+10-10
......@@ -1,4 +1,5 @@
11import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
2import { LruCache } from "./lib/lru";
23
34export interface ShortcutEventLike {
45 key: string;
......@@ -44,8 +45,7 @@ type WhenToken =
4445 | { type: "lparen" }
4546 | { type: "rparen" };
4647
47const WHEN_AST_CACHE = new Map<string, WhenNode | null>();
48const MAX_WHEN_AST_CACHE_ENTRIES = 512;
48const WHEN_AST_CACHE = new LruCache<string, WhenNode | null>(512);
4949
5050function isMacPlatform(platform: string): boolean {
5151 return /mac|iphone|ipad|ipod/i.test(platform);
......@@ -290,15 +290,15 @@ function matchesWhenExpression(when: string | undefined, context: ShortcutMatchC
290290 const normalized = when.trim();
291291 if (normalized.length === 0) return true;
292292
293 if (!WHEN_AST_CACHE.has(normalized)) {
294 if (WHEN_AST_CACHE.size >= MAX_WHEN_AST_CACHE_ENTRIES) {
295 WHEN_AST_CACHE.clear();
296 }
297 WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized));
293 const cached = WHEN_AST_CACHE.get(normalized);
294 if (cached === undefined) {
295 const parsed = parseWhenExpression(normalized);
296 WHEN_AST_CACHE.set(normalized, parsed);
297 if (!parsed) return false;
298 return evaluateWhenNode(parsed, context);
298299 }
299 const ast = WHEN_AST_CACHE.get(normalized);
300 if (!ast) return false;
301 return evaluateWhenNode(ast, context);
300 if (!cached) return false;
301 return evaluateWhenNode(cached, context);
302302}
303303
304304function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
apps/web/src/lib/lru.test.ts created+33
......@@ -0,0 +1,33 @@
1import { assert, describe, it } from "vitest";
2import { LruCache } from "./lru";
3
4describe("LruCache", () => {
5 it("evicts the least recently used entry", () => {
6 const cache = new LruCache<string, number>(2);
7 cache.set("a", 1);
8 cache.set("b", 2);
9 assert.strictEqual(cache.get("a"), 1);
10
11 cache.set("c", 3);
12
13 assert.strictEqual(cache.get("b"), undefined);
14 assert.strictEqual(cache.get("a"), 1);
15 assert.strictEqual(cache.get("c"), 3);
16 });
17
18 it("refreshes recency on update", () => {
19 const cache = new LruCache<string, number>(2);
20 cache.set("a", 1);
21 cache.set("b", 2);
22 cache.set("a", 10);
23 cache.set("c", 3);
24
25 assert.strictEqual(cache.get("b"), undefined);
26 assert.strictEqual(cache.get("a"), 10);
27 assert.strictEqual(cache.get("c"), 3);
28 });
29
30 it("rejects non-positive capacities", () => {
31 assert.throws(() => new LruCache<string, number>(0), "positive integer");
32 });
33});
apps/web/src/lib/lru.ts created+38
......@@ -0,0 +1,38 @@
1export class LruCache<K, V> {
2 private readonly entries = new Map<K, V>();
3
4 constructor(private readonly maxEntries: number) {
5 if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
6 throw new Error("LruCache maxEntries must be a positive integer.");
7 }
8 }
9
10 get size(): number {
11 return this.entries.size;
12 }
13
14 get(key: K): V | undefined {
15 const value = this.entries.get(key);
16 if (value === undefined && !this.entries.has(key)) {
17 return undefined;
18 }
19
20 // Refresh recency on successful lookup.
21 this.entries.delete(key);
22 this.entries.set(key, value as V);
23 return value;
24 }
25
26 set(key: K, value: V): void {
27 if (this.entries.has(key)) {
28 this.entries.delete(key);
29 }
30 this.entries.set(key, value);
31
32 while (this.entries.size > this.maxEntries) {
33 const oldest = this.entries.keys().next().value as K | undefined;
34 if (oldest === undefined) break;
35 this.entries.delete(oldest);
36 }
37 }
38}