perf(tui): hoist Markdown render cache to module-level LRU

Per-instance #cachedLines is always cold when session-tree navigation
creates a fresh Markdown component. nativeHighlightCode (sync Rust FFI
in theme.ts) runs on every code block at every re-render for every new
instance, which dominates render cost during rapid navigation.

Add a module-level ordered-Map LRU (256 entries) keyed on:
  normalizedText + width + paddingX + paddingY + codeBlockIndent
  + objectId(theme) + objectId(defaultTextStyle)

Theme identity uses a WeakMap<object,number> ID so GC can collect
orphaned themes without leaking. The per-instance #cachedLines field
is kept as L1 (fastest path for repeated renders of the same instance).
L2 is checked on L1 miss and populated on first render so subsequent
distinct instances with identical inputs skip marked.lexer + FFI entirely.

Output is byte-identical to the old code (cache is invisible to callers).
New test: two distinct Markdown instances with the same (text,width,theme)
invoke highlightCode exactly once total.
This commit is contained in:
cognitive
2026-04-27 03:34:03 +00:00
parent 9e353d3363
commit dd7357ec0d
2 changed files with 106 additions and 5 deletions
+74 -5
View File
@@ -4,6 +4,56 @@ import { TERMINAL } from "../terminal-capabilities";
import type { Component } from "../tui";
import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
// ---------------------------------------------------------------------------
// Module-level LRU render cache
// ---------------------------------------------------------------------------
// Each session-tree navigation discards and recreates Markdown component
// instances, so the per-instance #cachedLines field is always cold on first
// render of a fresh component. This module-level cache survives across
// component lifetimes and eliminates redundant marked.lexer + highlightCode
// (Rust FFI) work for content/layout combinations already seen this session.
//
// Implementation: a plain Map used as an ordered LRU. JavaScript Maps iterate
// in insertion order; we delete-then-reinsert on access to move an entry to
// the "most recent" end, and evict the first (oldest) entry on overflow.
// This avoids the lru-cache package dep (not yet in tui/package.json) while
// keeping the diff contained to this file.
const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
const _renderCache = new Map<string, string[]>();
function renderCacheGet(key: string): string[] | undefined {
const v = _renderCache.get(key);
if (v === undefined) return undefined;
// Move to MRU position.
_renderCache.delete(key);
_renderCache.set(key, v);
return v;
}
function renderCacheSet(key: string, value: string[]): void {
if (_renderCache.has(key)) {
_renderCache.delete(key);
} else if (_renderCache.size >= RENDER_CACHE_MAX) {
// Evict LRU (first inserted = Map iterator's first entry).
_renderCache.delete(_renderCache.keys().next().value!);
}
_renderCache.set(key, value);
}
// Stable numeric IDs for structural theme/style objects (no ID field on type).
// WeakMap so GC can collect orphaned themes/styles without a leak.
const _objectIds = new WeakMap<object, number>();
let _nextObjectId = 0;
function objectId(o: object): number {
let id = _objectIds.get(o);
if (id === undefined) {
id = _nextObjectId++;
_objectIds.set(o, id);
}
return id;
}
/**
* Default text styling for markdown content.
* Applied to all text unless overridden by markdown formatting.
@@ -116,7 +166,8 @@ export class Markdown implements Component {
}
render(width: number): string[] {
// Check cache
// L1: per-instance cache — fastest path for repeated renders of the same
// instance at the same width (e.g. resize debounce, repeated redraws).
if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
return this.#cachedLines;
}
@@ -127,7 +178,7 @@ export class Markdown implements Component {
// Don't render anything if there's no actual text
if (!this.#text || this.#text.trim() === "") {
const result: string[] = [];
// Update cache
// Update per-instance cache
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = result;
@@ -137,6 +188,19 @@ export class Markdown implements Component {
// Replace tabs with 3 spaces for consistent rendering
const normalizedText = replaceTabs(this.#text);
// L2: module-level LRU — survives component disposal/recreation across
// session-tree navigations. Key encodes every dimension that affects the
// render output so different configurations never collide.
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}`;
const cached = renderCacheGet(cacheKey);
if (cached !== undefined) {
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = cached;
return cached;
}
// Parse markdown to HTML-like tokens
const tokens = marked.lexer(normalizedText);
@@ -195,14 +259,19 @@ export class Markdown implements Component {
}
// Combine top padding, content, and bottom padding
const result = [...emptyLines, ...contentLines, ...emptyLines];
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
const result = rawResult.length > 0 ? rawResult : [""];
// Update cache
// Update L1 per-instance cache
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = result;
return result.length > 0 ? result : [""];
// Update L2 module-level LRU so future instances with the same key skip
// the marked.lexer + highlightCode (Rust FFI) work entirely.
renderCacheSet(cacheKey, result);
return result;
}
/**
+32
View File
@@ -1142,3 +1142,35 @@ bar`,
});
});
});
describe("Module-level LRU render cache", () => {
it("invokes highlightCode only once for two distinct instances with identical (text, width, theme)", () => {
// Build a theme with a spy on highlightCode. The theme object reference
// is stable across both instances so objectId() returns the same ID,
// meaning the L2 cache key is identical for both renders.
let highlightCallCount = 0;
const themeWithSpy = {
...defaultMarkdownTheme,
highlightCode: (code: string, _lang?: string): string[] => {
highlightCallCount++;
return [code]; // trivial passthrough
},
};
const text = "```js\nconst x = 1;\n```";
const width = 80;
// First instance: cold cache → highlightCode MUST be called.
const md1 = new Markdown(text, 0, 0, themeWithSpy);
const lines1 = md1.render(width);
expect(highlightCallCount, "First render should call highlightCode exactly once").toBe(1);
// Second distinct instance with identical inputs: L2 cache hit → highlightCode must NOT be called again.
const md2 = new Markdown(text, 0, 0, themeWithSpy);
const lines2 = md2.render(width);
expect(highlightCallCount, "Second render (different instance, same key) must use L2 cache").toBe(1);
// Output must be byte-identical — cache is transparent to callers.
expect(lines2).toEqual(lines1);
});
});