From 08cd02cd3798882394073f967d027b62858f9ed6 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 29 Apr 2026 21:59:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20highlight=20=E7=BC=93=E5=AD=98=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20LRUCache=20=E9=99=8D=E4=BD=8E=E5=86=85=E5=AD=98?= =?UTF-8?q?=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fallback.tsx: 手动 Map LRU 替换为 lru-cache 的 LRUCache - Markdown.tsx: tokenCache 同样替换为 LRUCache - color-diff-napi: 新增行级 hljs AST 缓存,避免终端 resize 时重复高亮 Co-Authored-By: Claude Opus 4.7 --- packages/color-diff-napi/src/index.ts | 68 ++++++++++++++------- src/components/HighlightedCode/Fallback.tsx | 14 +---- src/components/Markdown.tsx | 17 +----- 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/packages/color-diff-napi/src/index.ts b/packages/color-diff-napi/src/index.ts index 692728e2a..cd7f01457 100644 --- a/packages/color-diff-napi/src/index.ts +++ b/packages/color-diff-napi/src/index.ts @@ -502,6 +502,50 @@ function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } { 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() +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( state: { lang: string | null; stack: unknown }, line: string, @@ -512,30 +556,12 @@ function highlightLine( if (!state.lang) { return [[defaultStyle(theme), code]] } - let result - try { - 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.`, - ), - ) - } + const rootNode = cachedHljsAst(state.lang, code) + if (!rootNode) { return [[defaultStyle(theme), code]] } const blocks: Block[] = [] - flattenHljs(emitter.rootNode, theme, undefined, blocks) + flattenHljs(rootNode, theme, undefined, blocks) return blocks } diff --git a/src/components/HighlightedCode/Fallback.tsx b/src/components/HighlightedCode/Fallback.tsx index e81d44f3d..81f248eae 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -1,6 +1,7 @@ import { extname } from 'path' import React, { Suspense, use, useMemo } from 'react' import { Ansi, Text } from '@anthropic/ink' +import { LRUCache } from 'lru-cache' import { getCliHighlightPromise } from '../../utils/cliHighlight.js' import { logForDebugging } from '../../utils/debug.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- // scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash // of code+language to avoid retaining full source strings (#24180 RSS fix). -const HL_CACHE_MAX = 500 -const hlCache = new Map() +const hlCache = new LRUCache({ max: 500 }) function cachedHighlight( hl: NonNullable>>, code: string, @@ -25,16 +25,8 @@ function cachedHighlight( ): string { const key = hashPair(language, code) const hit = hlCache.get(key) - if (hit !== undefined) { - hlCache.delete(key) - hlCache.set(key, hit) - return hit - } + if (hit !== undefined) return hit 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) return out } diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 063404baf..cdc90f701 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,5 +1,6 @@ import { marked, type Token, type Tokens } from 'marked' import React, { Suspense, use, useMemo, useRef } from 'react' +import { LRUCache } from 'lru-cache' import { useSettings } from '../hooks/useSettings.js' import { Ansi, Box, useTheme } from '@anthropic/ink' import { @@ -22,8 +23,7 @@ type Props = { // scrolling back to a previously-visible message re-parses. Messages are // immutable in history; same content → same tokens. Keyed by hash to avoid // retaining full content strings (turn50→turn99 RSS regression, #24180). -const TOKEN_CACHE_MAX = 500 -const tokenCache = new Map() +const tokenCache = new LRUCache({ max: 500 }) // Characters that indicate markdown syntax. If none are present, skip the // ~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 hit = tokenCache.get(key) - if (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 - } + if (hit) return hit 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) return tokens }