fix: highlight 缓存改用 LRUCache 降低内存开销

- Fallback.tsx: 手动 Map LRU 替换为 lru-cache 的 LRUCache
- Markdown.tsx: tokenCache 同样替换为 LRUCache
- color-diff-napi: 新增行级 hljs AST 缓存,避免终端 resize 时重复高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-29 21:59:10 +08:00
parent 7effbca8db
commit 08cd02cd37
3 changed files with 53 additions and 46 deletions

View File

@@ -502,6 +502,50 @@ function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } {
let loggedEmitterShapeError = false let loggedEmitterShapeError = false
// Per-line hljs AST cache — ColorFile.render re-highlights every line on
// width change (terminal resize). The AST is theme-independent; flattenHljs
// applies theme colors separately. Capped at 2048 entries (~1 MB typical).
const HL_LINE_CACHE_MAX = 2048
const hlLineCache = new Map<string, HljsNode | null>()
function cachedHljsAst(
lang: string,
code: string,
): HljsNode | null {
const key = lang + '\0' + code
const hit = hlLineCache.get(key)
if (hit !== undefined) return hit
let result
try {
result = hljsApi().highlight(code, {
language: lang,
ignoreIllegals: true,
})
} catch {
hlLineCache.set(key, null)
return null
}
const emitter = result._emitter || {}
if (!hasRootNode(emitter)) {
if (!loggedEmitterShapeError) {
loggedEmitterShapeError = true
logError(
new Error(
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
),
)
}
hlLineCache.set(key, null)
return null
}
const node = emitter.rootNode
if (hlLineCache.size >= HL_LINE_CACHE_MAX) {
const first = hlLineCache.keys().next().value
if (first !== undefined) hlLineCache.delete(first)
}
hlLineCache.set(key, node)
return node
}
function highlightLine( function highlightLine(
state: { lang: string | null; stack: unknown }, state: { lang: string | null; stack: unknown },
line: string, line: string,
@@ -512,30 +556,12 @@ function highlightLine(
if (!state.lang) { if (!state.lang) {
return [[defaultStyle(theme), code]] return [[defaultStyle(theme), code]]
} }
let result const rootNode = cachedHljsAst(state.lang, code)
try { if (!rootNode) {
result = hljsApi().highlight(code, {
language: state.lang,
ignoreIllegals: true,
})
} catch {
// hljs throws on unknown language despite ignoreIllegals
return [[defaultStyle(theme), code]]
}
const emitter = result._emitter || {};
if (!hasRootNode(emitter)) {
if (!loggedEmitterShapeError) {
loggedEmitterShapeError = true
logError(
new Error(
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
),
)
}
return [[defaultStyle(theme), code]] return [[defaultStyle(theme), code]]
} }
const blocks: Block[] = [] const blocks: Block[] = []
flattenHljs(emitter.rootNode, theme, undefined, blocks) flattenHljs(rootNode, theme, undefined, blocks)
return blocks return blocks
} }

View File

@@ -1,6 +1,7 @@
import { extname } from 'path' import { extname } from 'path'
import React, { Suspense, use, useMemo } from 'react' import React, { Suspense, use, useMemo } from 'react'
import { Ansi, Text } from '@anthropic/ink' import { Ansi, Text } from '@anthropic/ink'
import { LRUCache } from 'lru-cache'
import { getCliHighlightPromise } from '../../utils/cliHighlight.js' import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
import { logForDebugging } from '../../utils/debug.js' import { logForDebugging } from '../../utils/debug.js'
import { convertLeadingTabsToSpaces } from '../../utils/file.js' import { convertLeadingTabsToSpaces } from '../../utils/file.js'
@@ -16,8 +17,7 @@ type Props = {
// Module-level highlight cache — hl.highlight() is the hot cost on virtual- // Module-level highlight cache — hl.highlight() is the hot cost on virtual-
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash // scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
// of code+language to avoid retaining full source strings (#24180 RSS fix). // of code+language to avoid retaining full source strings (#24180 RSS fix).
const HL_CACHE_MAX = 500 const hlCache = new LRUCache<string, string>({ max: 500 })
const hlCache = new Map<string, string>()
function cachedHighlight( function cachedHighlight(
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>, hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
code: string, code: string,
@@ -25,16 +25,8 @@ function cachedHighlight(
): string { ): string {
const key = hashPair(language, code) const key = hashPair(language, code)
const hit = hlCache.get(key) const hit = hlCache.get(key)
if (hit !== undefined) { if (hit !== undefined) return hit
hlCache.delete(key)
hlCache.set(key, hit)
return hit
}
const out = hl.highlight(code, { language }) const out = hl.highlight(code, { language })
if (hlCache.size >= HL_CACHE_MAX) {
const first = hlCache.keys().next().value
if (first !== undefined) hlCache.delete(first)
}
hlCache.set(key, out) hlCache.set(key, out)
return out return out
} }

View File

@@ -1,5 +1,6 @@
import { marked, type Token, type Tokens } from 'marked' import { marked, type Token, type Tokens } from 'marked'
import React, { Suspense, use, useMemo, useRef } from 'react' import React, { Suspense, use, useMemo, useRef } from 'react'
import { LRUCache } from 'lru-cache'
import { useSettings } from '../hooks/useSettings.js' import { useSettings } from '../hooks/useSettings.js'
import { Ansi, Box, useTheme } from '@anthropic/ink' import { Ansi, Box, useTheme } from '@anthropic/ink'
import { import {
@@ -22,8 +23,7 @@ type Props = {
// scrolling back to a previously-visible message re-parses. Messages are // scrolling back to a previously-visible message re-parses. Messages are
// immutable in history; same content → same tokens. Keyed by hash to avoid // immutable in history; same content → same tokens. Keyed by hash to avoid
// retaining full content strings (turn50→turn99 RSS regression, #24180). // retaining full content strings (turn50→turn99 RSS regression, #24180).
const TOKEN_CACHE_MAX = 500 const tokenCache = new LRUCache<string, Token[]>({ max: 500 })
const tokenCache = new Map<string, Token[]>()
// Characters that indicate markdown syntax. If none are present, skip the // Characters that indicate markdown syntax. If none are present, skip the
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers // ~3ms marked.lexer call entirely — render as a single paragraph. Covers
@@ -55,19 +55,8 @@ function cachedLexer(content: string): Token[] {
} }
const key = hashContent(content) const key = hashContent(content)
const hit = tokenCache.get(key) const hit = tokenCache.get(key)
if (hit) { if (hit) return hit
// Promote to MRU — without this the eviction is FIFO (scrolling back to
// an early message evicts the very item you're looking at).
tokenCache.delete(key)
tokenCache.set(key, hit)
return hit
}
const tokens = marked.lexer(content) const tokens = marked.lexer(content)
if (tokenCache.size >= TOKEN_CACHE_MAX) {
// LRU-ish: drop oldest. Map preserves insertion order.
const first = tokenCache.keys().next().value
if (first !== undefined) tokenCache.delete(first)
}
tokenCache.set(key, tokens) tokenCache.set(key, tokens)
return tokens return tokens
} }