diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 58e84ee8c859f7fefba68ff9047fafa44fe05324..70217185c5672a9627608b3d4e579110a3cdfbd8 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -1,4 +1,5 @@ import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; +import { LruCache } from "./lib/lru"; export interface ShortcutEventLike { key: string; @@ -44,8 +45,7 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; -const WHEN_AST_CACHE = new Map(); -const MAX_WHEN_AST_CACHE_ENTRIES = 512; +const WHEN_AST_CACHE = new LruCache(512); function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); @@ -290,15 +290,15 @@ function matchesWhenExpression(when: string | undefined, context: ShortcutMatchC const normalized = when.trim(); if (normalized.length === 0) return true; - if (!WHEN_AST_CACHE.has(normalized)) { - if (WHEN_AST_CACHE.size >= MAX_WHEN_AST_CACHE_ENTRIES) { - WHEN_AST_CACHE.clear(); - } - WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); + const cached = WHEN_AST_CACHE.get(normalized); + if (cached === undefined) { + const parsed = parseWhenExpression(normalized); + WHEN_AST_CACHE.set(normalized, parsed); + if (!parsed) return false; + return evaluateWhenNode(parsed, context); } - const ast = WHEN_AST_CACHE.get(normalized); - if (!ast) return false; - return evaluateWhenNode(ast, context); + if (!cached) return false; + return evaluateWhenNode(cached, context); } function resolvePlatform(options: ShortcutMatchOptions | undefined): string { diff --git a/apps/web/src/lib/lru.test.ts b/apps/web/src/lib/lru.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d001796d625ba198853a5a5ee1e0606445ead02 --- /dev/null +++ b/apps/web/src/lib/lru.test.ts @@ -0,0 +1,33 @@ +import { assert, describe, it } from "vitest"; +import { LruCache } from "./lru"; + +describe("LruCache", () => { + it("evicts the least recently used entry", () => { + const cache = new LruCache(2); + cache.set("a", 1); + cache.set("b", 2); + assert.strictEqual(cache.get("a"), 1); + + cache.set("c", 3); + + assert.strictEqual(cache.get("b"), undefined); + assert.strictEqual(cache.get("a"), 1); + assert.strictEqual(cache.get("c"), 3); + }); + + it("refreshes recency on update", () => { + const cache = new LruCache(2); + cache.set("a", 1); + cache.set("b", 2); + cache.set("a", 10); + cache.set("c", 3); + + assert.strictEqual(cache.get("b"), undefined); + assert.strictEqual(cache.get("a"), 10); + assert.strictEqual(cache.get("c"), 3); + }); + + it("rejects non-positive capacities", () => { + assert.throws(() => new LruCache(0), "positive integer"); + }); +}); diff --git a/apps/web/src/lib/lru.ts b/apps/web/src/lib/lru.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4f61bd3f32f55feb9e954703a9bd779a1838e74 --- /dev/null +++ b/apps/web/src/lib/lru.ts @@ -0,0 +1,38 @@ +export class LruCache { + private readonly entries = new Map(); + + constructor(private readonly maxEntries: number) { + if (!Number.isInteger(maxEntries) || maxEntries <= 0) { + throw new Error("LruCache maxEntries must be a positive integer."); + } + } + + get size(): number { + return this.entries.size; + } + + get(key: K): V | undefined { + const value = this.entries.get(key); + if (value === undefined && !this.entries.has(key)) { + return undefined; + } + + // Refresh recency on successful lookup. + this.entries.delete(key); + this.entries.set(key, value as V); + return value; + } + + set(key: K, value: V): void { + if (this.entries.has(key)) { + this.entries.delete(key); + } + this.entries.set(key, value); + + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as K | undefined; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } +}