From d35f0db17d1e8e89b6de7cc206ec4445c7338211 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 | 94 ++++++++----- src/components/HighlightedCode/Fallback.tsx | 90 +++++------- src/components/Markdown.tsx | 148 ++++++++------------ 3 files changed, 154 insertions(+), 178 deletions(-) diff --git a/packages/color-diff-napi/src/index.ts b/packages/color-diff-napi/src/index.ts index afaf924ea..cd7f01457 100644 --- a/packages/color-diff-napi/src/index.ts +++ b/packages/color-diff-napi/src/index.ts @@ -18,26 +18,20 @@ */ import { diffArrays } from 'diff' -import type * as hljsNamespace from 'highlight.js' +import hljs from 'highlight.js' import { basename, extname } from 'path' -// Lazy: defers loading highlight.js until first render. The full bundle -// registers 190+ language grammars at require time (~50MB, 100-200ms on -// macOS, several× that on Windows). With a top-level import, any caller -// chunk that reaches this module — including test/preload.ts via -// StructuredDiff.tsx → colorDiff.ts — pays that cost at module-eval time -// and carries the heap for the rest of the process. On Windows CI this -// pushed later tests in the same shard into GC-pause territory and a -// beforeEach/afterEach hook timeout (officialRegistry.test.ts, PR #24150). -// Same lazy pattern the NAPI wrapper used for dlopen. -type HLJSApi = typeof hljsNamespace.default +// Static import — createRequire(import.meta.url) fails in Bun --compile mode +// because the resolved path points to the internal bunfs binary path where +// node_modules cannot be found. A top-level import ensures the module is +// bundled and accessible at runtime. +type HLJSApi = typeof hljs let cachedHljs: HLJSApi | null = null -function hljs(): HLJSApi { +function hljsApi(): HLJSApi { if (cachedHljs) return cachedHljs - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod = require('highlight.js') // highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it // in .default; under node CJS the module IS the API. Check at runtime. + const mod = hljs as HLJSApi & { default?: HLJSApi } cachedHljs = 'default' in mod && mod.default ? mod.default : mod return cachedHljs! } @@ -436,9 +430,9 @@ function detectLanguage( // Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.) const stem = base.split('.')[0] ?? '' const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem] - if (byName && hljs().getLanguage(byName)) return byName + if (byName && hljsApi().getLanguage(byName)) return byName if (ext) { - const lang = hljs().getLanguage(ext) + const lang = hljsApi().getLanguage(ext) if (lang) return ext } // Shebang / first-line detection (strip UTF-8 BOM) @@ -508,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, @@ -518,30 +556,12 @@ function highlightLine( if (!state.lang) { return [[defaultStyle(theme), code]] } - let result - try { - result = hljs().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..92a3f234f 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -1,42 +1,34 @@ -import { extname } from 'path' -import React, { Suspense, use, useMemo } from 'react' -import { Ansi, Text } from '@anthropic/ink' -import { getCliHighlightPromise } from '../../utils/cliHighlight.js' -import { logForDebugging } from '../../utils/debug.js' -import { convertLeadingTabsToSpaces } from '../../utils/file.js' -import { hashPair } from '../../utils/hash.js' +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'; +import { hashPair } from '../../utils/hash.js'; type Props = { - code: string - filePath: string - dim?: boolean - skipColoring?: boolean -} + code: string; + filePath: string; + dim?: boolean; + skipColoring?: boolean; +}; // 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, language: string, ): string { - const key = hashPair(language, code) - const hit = hlCache.get(key) - if (hit !== undefined) { - hlCache.delete(key) - hlCache.set(key, hit) - 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 + const key = hashPair(language, code); + const hit = hlCache.get(key); + if (hit !== undefined) return hit; + const out = hl.highlight(code, { language }); + hlCache.set(key, out); + return out; } export function HighlightedCodeFallback({ @@ -45,55 +37,45 @@ export function HighlightedCodeFallback({ dim = false, skipColoring = false, }: Props): React.ReactElement { - const codeWithSpaces = convertLeadingTabsToSpaces(code) + const codeWithSpaces = convertLeadingTabsToSpaces(code); if (skipColoring) { return ( {codeWithSpaces} - ) + ); } - const language = extname(filePath).slice(1) + const language = extname(filePath).slice(1); return ( {codeWithSpaces}}> - ) + ); } -function Highlighted({ - codeWithSpaces, - language, -}: { - codeWithSpaces: string - language: string -}): React.ReactElement { - const hl = use(getCliHighlightPromise()) +function Highlighted({ codeWithSpaces, language }: { codeWithSpaces: string; language: string }): React.ReactElement { + const hl = use(getCliHighlightPromise()); const out = useMemo(() => { - if (!hl) return codeWithSpaces - let highlightLang = 'markdown' + if (!hl) return codeWithSpaces; + let highlightLang = 'markdown'; if (language) { if (hl.supportsLanguage(language)) { - highlightLang = language + highlightLang = language; } else { - logForDebugging( - `Language not supported while highlighting code, falling back to markdown: ${language}`, - ) + logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${language}`); } } try { - return cachedHighlight(hl, codeWithSpaces, highlightLang) + return cachedHighlight(hl, codeWithSpaces, highlightLang); } catch (e) { if (e instanceof Error && e.message.includes('Unknown language')) { - logForDebugging( - `Language not supported while highlighting code, falling back to markdown: ${e}`, - ) - return cachedHighlight(hl, codeWithSpaces, 'markdown') + logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${e}`); + return cachedHighlight(hl, codeWithSpaces, 'markdown'); } - return codeWithSpaces + return codeWithSpaces; } - }, [codeWithSpaces, language, hl]) - return {out} + }, [codeWithSpaces, language, hl]); + return {out}; } diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 063404baf..1259c61cf 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,29 +1,26 @@ -import { marked, type Token, type Tokens } from 'marked' -import React, { Suspense, use, useMemo, useRef } from 'react' -import { useSettings } from '../hooks/useSettings.js' -import { Ansi, Box, useTheme } from '@anthropic/ink' -import { - type CliHighlight, - getCliHighlightPromise, -} from '../utils/cliHighlight.js' -import { hashContent } from '../utils/hash.js' -import { configureMarked, formatToken } from '../utils/markdown.js' -import { stripPromptXMLTags } from '../utils/messages.js' -import { MarkdownTable } from './MarkdownTable.js' +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 { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js'; +import { hashContent } from '../utils/hash.js'; +import { configureMarked, formatToken } from '../utils/markdown.js'; +import { stripPromptXMLTags } from '../utils/messages.js'; +import { MarkdownTable } from './MarkdownTable.js'; type Props = { - children: string + children: string; /** When true, render all text content as dim */ - dimColor?: boolean -} + dimColor?: boolean; +}; // Module-level token cache — marked.lexer is the hot cost on virtual-scroll // remounts (~3ms per message). useMemo doesn't survive unmount→remount, so // 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 @@ -31,11 +28,11 @@ const tokenCache = new Map() // plain sentences. Checked via indexOf (not regex) for speed. // Single regex: matches any MD marker or ordered-list start (N. at line start). // One pass instead of 10× includes scans. -const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. / +const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /; function hasMarkdownSyntax(s: string): boolean { // Sample first 500 chars — if markdown exists it's usually early (headers, // code fence, list). Long tool outputs are mostly plain text tails. - return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s) + return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s); } function cachedLexer(content: string): Token[] { @@ -51,25 +48,14 @@ function cachedLexer(content: string): Token[] { text: content, tokens: [{ type: 'text', raw: content, text: content }], } as 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 - } - 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 + const key = hashContent(content); + const hit = tokenCache.get(key); + if (hit) return hit; + const tokens = marked.lexer(content); + tokenCache.set(key, tokens); + return tokens; } /** @@ -78,9 +64,9 @@ function cachedLexer(content: string): Token[] { * - Other content is rendered as ANSI strings via formatToken */ export function Markdown(props: Props): React.ReactNode { - const settings = useSettings() + const settings = useSettings(); if (settings.syntaxHighlightingDisabled) { - return + return ; } // Suspense fallback renders with highlight=null — plain markdown shows // for ~50ms on first ever render while cli-highlight loads. @@ -88,26 +74,22 @@ export function Markdown(props: Props): React.ReactNode { }> - ) + ); } function MarkdownWithHighlight(props: Props): React.ReactNode { - const highlight = use(getCliHighlightPromise()) - return + const highlight = use(getCliHighlightPromise()); + return ; } -function MarkdownBody({ - children, - dimColor, - highlight, -}: Props & { highlight: CliHighlight | null }): React.ReactNode { - const [theme] = useTheme() - configureMarked() +function MarkdownBody({ children, dimColor, highlight }: Props & { highlight: CliHighlight | null }): React.ReactNode { + const [theme] = useTheme(); + configureMarked(); const elements = useMemo(() => { - const tokens = cachedLexer(stripPromptXMLTags(children)) - const elements: React.ReactNode[] = [] - let nonTableContent = '' + const tokens = cachedLexer(stripPromptXMLTags(children)); + const elements: React.ReactNode[] = []; + let nonTableContent = ''; function flushNonTableContent(): void { if (nonTableContent) { @@ -115,40 +97,34 @@ function MarkdownBody({ {nonTableContent.trim()} , - ) - nonTableContent = '' + ); + nonTableContent = ''; } } for (const token of tokens) { if (token.type === 'table') { - flushNonTableContent() - elements.push( - , - ) + flushNonTableContent(); + elements.push(); } else { - nonTableContent += formatToken(token, theme, 0, null, null, highlight) + nonTableContent += formatToken(token, theme, 0, null, null, highlight); } } - flushNonTableContent() - return elements - }, [children, dimColor, highlight, theme]) + flushNonTableContent(); + return elements; + }, [children, dimColor, highlight, theme]); return ( {elements} - ) + ); } type StreamingProps = { - children: string -} + children: string; +}; /** * Renders markdown during streaming by splitting at the last top-level block @@ -160,49 +136,47 @@ type StreamingProps = { * is idempotent and safe under StrictMode double-rendering. Component unmounts * between turns (streamingText → null), resetting the ref. */ -export function StreamingMarkdown({ - children, -}: StreamingProps): React.ReactNode { +export function StreamingMarkdown({ children }: StreamingProps): React.ReactNode { // React Compiler: this component reads and writes stablePrefixRef.current // during render by design. The boundary only advances (monotonic), so // the ref mutation is idempotent under StrictMode double-render — but the // compiler can't prove that, and memoizing around the ref reads would // break the algorithm (stale boundary). Opt out. - 'use no memo' - configureMarked() + 'use no memo'; + configureMarked(); // Strip before boundary tracking so it matches 's stripping // (line 29). When a closing tag arrives, stripped(N+1) is not a prefix // of stripped(N), but the startsWith reset below handles that with a // one-time re-lex on the smaller stripped string. - const stripped = stripPromptXMLTags(children) + const stripped = stripPromptXMLTags(children); - const stablePrefixRef = useRef('') + const stablePrefixRef = useRef(''); // Reset if text was replaced (defensive; normally unmount handles this) if (!stripped.startsWith(stablePrefixRef.current)) { - stablePrefixRef.current = '' + stablePrefixRef.current = ''; } // Lex only from current boundary — O(unstable length), not O(full text) - const boundary = stablePrefixRef.current.length - const tokens = marked.lexer(stripped.substring(boundary)) + const boundary = stablePrefixRef.current.length; + const tokens = marked.lexer(stripped.substring(boundary)); // Last non-space token is the growing block; everything before is final - let lastContentIdx = tokens.length - 1 + let lastContentIdx = tokens.length - 1; while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') { - lastContentIdx-- + lastContentIdx--; } - let advance = 0 + let advance = 0; for (let i = 0; i < lastContentIdx; i++) { - advance += tokens[i]!.raw.length + advance += tokens[i]!.raw.length; } if (advance > 0) { - stablePrefixRef.current = stripped.substring(0, boundary + advance) + stablePrefixRef.current = stripped.substring(0, boundary + advance); } - const stablePrefix = stablePrefixRef.current - const unstableSuffix = stripped.substring(stablePrefix.length) + const stablePrefix = stablePrefixRef.current; + const unstableSuffix = stripped.substring(stablePrefix.length); // stablePrefix is memoized inside via useMemo([children, ...]) // so it never re-parses as the unstable suffix grows @@ -211,5 +185,5 @@ export function StreamingMarkdown({ {stablePrefix && {stablePrefix}} {unstableSuffix && {unstableSuffix}} - ) + ); }