From 2db62b1d17e410cd7dce7fdbc4d1e67bdfe683f1 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 5 Jan 2026 19:45:33 +0000 Subject: [PATCH] Make UI global cache version-aware Store one cached value per cacheId and overwrite when version changes to prevent unbounded growth from per-version keys. --- packages/ui/src/components/markdown.tsx | 101 ++++++++++-------- packages/ui/src/components/message-part.tsx | 2 + packages/ui/src/components/tool-call.tsx | 33 +++--- packages/ui/src/lib/global-cache.ts | 20 +++- packages/ui/src/lib/hooks/use-global-cache.ts | 8 +- 5 files changed, 96 insertions(+), 68 deletions(-) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 6317f90c..eeb75486 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,19 +1,32 @@ -import { createEffect, createSignal, onMount, onCleanup } from "solid-js" -import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" +import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown" +import { useGlobalCache } from "../lib/hooks/use-global-cache" import type { TextPart, RenderCache } from "../types/message" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" + const log = getLogger("session") -const markdownRenderCache = new Map() +function hashText(value: string): string { + let hash = 2166136261 + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16) +} -function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean, versionKey: string) { - const versionSegment = versionKey.length > 0 ? versionKey : "noversion" - return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}:${versionSegment}` +function resolvePartVersion(part: TextPart, text: string): string { + if (typeof part.version === "number") { + return String(part.version) + } + return `text-${hashText(text)}` } interface MarkdownProps { part: TextPart + instanceId?: string + sessionId?: string isDark?: boolean size?: "base" | "sm" | "tight" disableHighlight?: boolean @@ -29,38 +42,49 @@ export function Markdown(props: MarkdownProps) { Promise.resolve().then(() => props.onRendered?.()) } - createEffect(async () => { + const resolved = createMemo(() => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" const text = decodeHtmlEntities(rawText) - const dark = Boolean(props.isDark) - const themeKey = dark ? "dark" : "light" + const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "" if (!partId) { throw new Error("Markdown rendering requires a part id") } - const versionKey = typeof part.version === "number" ? String(part.version) : "" - const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled, versionKey) + const version = resolvePartVersion(part, text) + return { part, text, themeKey, highlightEnabled, partId, version } + }) + + const cacheHandle = useGlobalCache({ + instanceId: () => props.instanceId, + sessionId: () => props.sessionId, + scope: "markdown", + cacheId: () => { + const { partId, themeKey, highlightEnabled } = resolved() + return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}` + }, + version: () => resolved().version, + }) + + createEffect(async () => { + const { part, text, themeKey, highlightEnabled, version } = resolved() latestRequestedText = text - const localCache = part.renderCache const cacheMatches = (cache: RenderCache | undefined) => { if (!cache) return false - if (versionKey.length > 0) { - return cache.mode === versionKey && cache.theme === themeKey - } - return cache.text === text && cache.theme === themeKey + return cache.theme === themeKey && cache.mode === version } + const localCache = part.renderCache if (localCache && cacheMatches(localCache)) { setHtml(localCache.html) notifyRendered() return } - const globalCache = markdownRenderCache.get(cacheKey) + const globalCache = cacheHandle.get() if (globalCache && cacheMatches(globalCache)) { setHtml(globalCache.html) part.renderCache = globalCache @@ -68,6 +92,14 @@ export function Markdown(props: MarkdownProps) { return } + const commitCacheEntry = (renderedHtml: string) => { + const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version } + setHtml(renderedHtml) + part.renderCache = cacheEntry + cacheHandle.set(cacheEntry) + notifyRendered() + } + if (!highlightEnabled) { part.renderCache = undefined @@ -75,20 +107,12 @@ export function Markdown(props: MarkdownProps) { const rendered = await renderMarkdown(text, { suppressHighlight: true }) if (latestRequestedText === text) { - const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined } - setHtml(rendered) - part.renderCache = cacheEntry - markdownRenderCache.set(cacheKey, cacheEntry) - notifyRendered() + commitCacheEntry(rendered) } } catch (error) { log.error("Failed to render markdown:", error) if (latestRequestedText === text) { - const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined } - setHtml(text) - part.renderCache = cacheEntry - markdownRenderCache.set(cacheKey, cacheEntry) - notifyRendered() + commitCacheEntry(text) } } return @@ -96,22 +120,13 @@ export function Markdown(props: MarkdownProps) { try { const rendered = await renderMarkdown(text) - if (latestRequestedText === text) { - const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined } - setHtml(rendered) - part.renderCache = cacheEntry - markdownRenderCache.set(cacheKey, cacheEntry) - notifyRendered() + commitCacheEntry(rendered) } } catch (error) { log.error("Failed to render markdown:", error) if (latestRequestedText === text) { - const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined } - setHtml(text) - part.renderCache = cacheEntry - markdownRenderCache.set(cacheKey, cacheEntry) - notifyRendered() + commitCacheEntry(text) } } }) @@ -147,15 +162,12 @@ export function Markdown(props: MarkdownProps) { containerRef?.addEventListener("click", handleClick) - // Register listener for language loading completion const cleanupLanguageListener = onLanguagesLoaded(async () => { if (props.disableHighlight) { return } - const part = props.part - const rawText = typeof part.text === "string" ? part.text : "" - const text = decodeHtmlEntities(rawText) + const { part, text, themeKey, version } = resolved() if (latestRequestedText !== text) { return @@ -164,9 +176,10 @@ export function Markdown(props: MarkdownProps) { try { const rendered = await renderMarkdown(text) if (latestRequestedText === text) { + const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version } setHtml(rendered) - const themeKey = Boolean(props.isDark) ? "dark" : "light" - part.renderCache = { text, html: rendered, theme: themeKey } + part.renderCache = cacheEntry + cacheHandle.set(cacheEntry) notifyRendered() } } catch (error) { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 542ee5af..421985bf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -102,6 +102,8 @@ interface MessagePartProps { > messageStoreBus.getOrCreate(props.instanceId)) - const createVariantCache = (variant: string | (() => string)) => + const cacheVersion = createMemo(() => { + if (typeof props.partVersion === "number") { + return String(props.partVersion) + } + if (typeof props.messageVersion === "number") { + return String(props.messageVersion) + } + return "noversion" + }) + + const createVariantCache = (variant: string | (() => string), version?: () => string) => useGlobalCache({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, scope: TOOL_CALL_CACHE_SCOPE, - key: () => { + cacheId: () => { const context = cacheContext() const resolvedVariant = typeof variant === "function" ? variant() : variant return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant) }, + version: () => (version ? version() : cacheVersion()), }) const diffCache = createVariantCache("diff") const permissionDiffCache = createVariantCache("permission-diff") - const markdownCache = createVariantCache("markdown") - const ansiRunningCache = createVariantCache(() => { - const versionKey = typeof props.partVersion === "number" ? String(props.partVersion) : "noversion" - return `ansi-running:${versionKey}` - }) - const ansiFinalCache = createVariantCache(() => { - const versionKey = typeof props.partVersion === "number" ? String(props.partVersion) : "noversion" - return `ansi-final:${versionKey}` - }) + const ansiRunningCache = createVariantCache("ansi-running", () => "running") + const ansiFinalCache = createVariantCache("ansi-final") const runningAnsiRenderer = createAnsiStreamRenderer() let runningAnsiSource = "" @@ -736,13 +740,8 @@ export default function ToolCall(props: ToolCallProps) { throw new Error("Tool call markdown requires a part id") } const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion } - const cached = markdownCache.get() - if (cached) { - markdownPart.renderCache = cached - } const handleMarkdownRendered = () => { - markdownCache.set(markdownPart.renderCache) handleScrollRendered() props.onContentRendered?.() } @@ -751,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> +type VersionedCacheEntry = { + version: string + value: unknown +} + +type CacheValueMap = Map type CacheScopeMap = Map type CacheSessionMap = Map @@ -83,18 +89,22 @@ export function setCacheEntry(params: CacheEntryParams, value: T | undefined) if (value === undefined) { const existingMap = getScopeValueMap(params, false) - existingMap?.delete(params.key) + existingMap?.delete(params.cacheId) cleanupHierarchy(instanceKey, sessionKey, params.scope) return } const scopeEntries = getScopeValueMap(params, true) - scopeEntries?.set(params.key, value) + scopeEntries?.set(params.cacheId, { version: params.version, value }) } export function getCacheEntry(params: CacheEntryParams): T | undefined { const scopeEntries = getScopeValueMap(params, false) - return scopeEntries?.get(params.key) as T | undefined + const entry = scopeEntries?.get(params.cacheId) + if (!entry || entry.version !== params.version) { + return undefined + } + return entry.value as T } export function clearCacheScope(params: CacheEntryBaseParams): void { diff --git a/packages/ui/src/lib/hooks/use-global-cache.ts b/packages/ui/src/lib/hooks/use-global-cache.ts index e5c81056..b3ba1aae 100644 --- a/packages/ui/src/lib/hooks/use-global-cache.ts +++ b/packages/ui/src/lib/hooks/use-global-cache.ts @@ -18,8 +18,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle const instanceId = normalizeId(resolveValue(params.instanceId)) const sessionId = normalizeId(resolveValue(params.sessionId)) const scope = resolveValue(params.scope) - const key = resolveValue(params.key) - return { instanceId, sessionId, scope, key } + const cacheId = resolveValue(params.cacheId) + const version = String(resolveValue(params.version)) + return { instanceId, sessionId, scope, cacheId, version } }) const scopeParams = createMemo(() => { @@ -73,7 +74,8 @@ interface UseGlobalCacheParams { instanceId?: MaybeAccessor sessionId?: MaybeAccessor scope: MaybeAccessor - key: MaybeAccessor + cacheId: MaybeAccessor + version: MaybeAccessor } interface GlobalCacheHandle {