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 @@...@@ -1,4 +1,5 @@
1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
2import { LruCache } from "./lib/lru";
23
3export interface ShortcutEventLike {4export interface ShortcutEventLike {
4 key: string;5 key: string;
...@@ -44,8 +45,7 @@ type WhenToken =...@@ -44,8 +45,7 @@ type WhenToken =
44 | { type: "lparen" }45 | { type: "lparen" }
45 | { type: "rparen" };46 | { type: "rparen" };
4647
47const WHEN_AST_CACHE = new Map<string, WhenNode | null>();48const WHEN_AST_CACHE = new LruCache<string, WhenNode | null>(512);
48const MAX_WHEN_AST_CACHE_ENTRIES = 512;
4949
50function isMacPlatform(platform: string): boolean {50function isMacPlatform(platform: string): boolean {
51 return /mac|iphone|ipad|ipod/i.test(platform);51 return /mac|iphone|ipad|ipod/i.test(platform);
...@@ -290,15 +290,15 @@ function matchesWhenExpression(when: string | undefined, context: ShortcutMatchC...@@ -290,15 +290,15 @@ function matchesWhenExpression(when: string | undefined, context: ShortcutMatchC
290 const normalized = when.trim();290 const normalized = when.trim();
291 if (normalized.length === 0) return true;291 if (normalized.length === 0) return true;
292292
293 if (!WHEN_AST_CACHE.has(normalized)) {293 const cached = WHEN_AST_CACHE.get(normalized);
294 if (WHEN_AST_CACHE.size >= MAX_WHEN_AST_CACHE_ENTRIES) {294 if (cached === undefined) {
295 WHEN_AST_CACHE.clear();295 const parsed = parseWhenExpression(normalized);
296 }296 WHEN_AST_CACHE.set(normalized, parsed);
297 WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized));297 if (!parsed) return false;
298 return evaluateWhenNode(parsed, context);
298 }299 }
299 const ast = WHEN_AST_CACHE.get(normalized);300 if (!cached) return false;
300 if (!ast) return false;301 return evaluateWhenNode(cached, context);
301 return evaluateWhenNode(ast, context);
302}302}
303303
304function resolvePlatform(options: ShortcutMatchOptions | undefined): string {304function 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}