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.
This commit is contained in:
Shantur Rathore
2026-01-05 19:45:33 +00:00
parent 1377bc6b91
commit 2db62b1d17
5 changed files with 96 additions and 68 deletions

View File

@@ -1,19 +1,32 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session") const log = getLogger("session")
const markdownRenderCache = new Map<string, RenderCache>() 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) { function resolvePartVersion(part: TextPart, text: string): string {
const versionSegment = versionKey.length > 0 ? versionKey : "noversion" if (typeof part.version === "number") {
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}:${versionSegment}` return String(part.version)
}
return `text-${hashText(text)}`
} }
interface MarkdownProps { interface MarkdownProps {
part: TextPart part: TextPart
instanceId?: string
sessionId?: string
isDark?: boolean isDark?: boolean
size?: "base" | "sm" | "tight" size?: "base" | "sm" | "tight"
disableHighlight?: boolean disableHighlight?: boolean
@@ -29,38 +42,49 @@ export function Markdown(props: MarkdownProps) {
Promise.resolve().then(() => props.onRendered?.()) Promise.resolve().then(() => props.onRendered?.())
} }
createEffect(async () => { const resolved = createMemo(() => {
const part = props.part const part = props.part
const rawText = typeof part.text === "string" ? part.text : "" const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText) const text = decodeHtmlEntities(rawText)
const dark = Boolean(props.isDark) const themeKey = Boolean(props.isDark) ? "dark" : "light"
const themeKey = dark ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "" const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (!partId) { if (!partId) {
throw new Error("Markdown rendering requires a part id") throw new Error("Markdown rendering requires a part id")
} }
const versionKey = typeof part.version === "number" ? String(part.version) : "" const version = resolvePartVersion(part, text)
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled, versionKey) 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 latestRequestedText = text
const localCache = part.renderCache
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
if (versionKey.length > 0) { return cache.theme === themeKey && cache.mode === version
return cache.mode === versionKey && cache.theme === themeKey
}
return cache.text === text && cache.theme === themeKey
} }
const localCache = part.renderCache
if (localCache && cacheMatches(localCache)) { if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html) setHtml(localCache.html)
notifyRendered() notifyRendered()
return return
} }
const globalCache = markdownRenderCache.get(cacheKey) const globalCache = cacheHandle.get<RenderCache>()
if (globalCache && cacheMatches(globalCache)) { if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html) setHtml(globalCache.html)
part.renderCache = globalCache part.renderCache = globalCache
@@ -68,6 +92,14 @@ export function Markdown(props: MarkdownProps) {
return 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) { if (!highlightEnabled) {
part.renderCache = undefined part.renderCache = undefined
@@ -75,20 +107,12 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true }) const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined } commitCacheEntry(rendered)
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} catch (error) { } catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined } commitCacheEntry(text)
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} }
return return
@@ -96,22 +120,13 @@ export function Markdown(props: MarkdownProps) {
try { try {
const rendered = await renderMarkdown(text) const rendered = await renderMarkdown(text)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined } commitCacheEntry(rendered)
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} catch (error) { } catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined } commitCacheEntry(text)
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
} }
} }
}) })
@@ -147,15 +162,12 @@ export function Markdown(props: MarkdownProps) {
containerRef?.addEventListener("click", handleClick) containerRef?.addEventListener("click", handleClick)
// Register listener for language loading completion
const cleanupLanguageListener = onLanguagesLoaded(async () => { const cleanupLanguageListener = onLanguagesLoaded(async () => {
if (props.disableHighlight) { if (props.disableHighlight) {
return return
} }
const part = props.part const { part, text, themeKey, version } = resolved()
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
if (latestRequestedText !== text) { if (latestRequestedText !== text) {
return return
@@ -164,9 +176,10 @@ export function Markdown(props: MarkdownProps) {
try { try {
const rendered = await renderMarkdown(text) const rendered = await renderMarkdown(text)
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered) setHtml(rendered)
const themeKey = Boolean(props.isDark) ? "dark" : "light" part.renderCache = cacheEntry
part.renderCache = { text, html: rendered, theme: themeKey } cacheHandle.set(cacheEntry)
notifyRendered() notifyRendered()
} }
} catch (error) { } catch (error) {

View File

@@ -102,6 +102,8 @@ interface MessagePartProps {
> >
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered} onRendered={props.onRendered}

View File

@@ -240,29 +240,33 @@ export default function ToolCall(props: ToolCallProps) {
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo(() => 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({ useGlobalCache({
instanceId: () => props.instanceId, instanceId: () => props.instanceId,
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
scope: TOOL_CALL_CACHE_SCOPE, scope: TOOL_CALL_CACHE_SCOPE,
key: () => { cacheId: () => {
const context = cacheContext() const context = cacheContext()
const resolvedVariant = typeof variant === "function" ? variant() : variant const resolvedVariant = typeof variant === "function" ? variant() : variant
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant) return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant)
}, },
version: () => (version ? version() : cacheVersion()),
}) })
const diffCache = createVariantCache("diff") const diffCache = createVariantCache("diff")
const permissionDiffCache = createVariantCache("permission-diff") const permissionDiffCache = createVariantCache("permission-diff")
const markdownCache = createVariantCache("markdown") const ansiRunningCache = createVariantCache("ansi-running", () => "running")
const ansiRunningCache = createVariantCache(() => { const ansiFinalCache = createVariantCache("ansi-final")
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 runningAnsiRenderer = createAnsiStreamRenderer() const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = "" let runningAnsiSource = ""
@@ -736,13 +740,8 @@ export default function ToolCall(props: ToolCallProps) {
throw new Error("Tool call markdown requires a part id") throw new Error("Tool call markdown requires a part id")
} }
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion } const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
const cached = markdownCache.get<RenderCache>()
if (cached) {
markdownPart.renderCache = cached
}
const handleMarkdownRendered = () => { const handleMarkdownRendered = () => {
markdownCache.set(markdownPart.renderCache)
handleScrollRendered() handleScrollRendered()
props.onContentRendered?.() props.onContentRendered?.()
} }
@@ -751,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> <div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<Markdown <Markdown
part={markdownPart} part={markdownPart}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
disableHighlight={disableHighlight} disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered} onRendered={handleMarkdownRendered}

View File

@@ -5,10 +5,16 @@ export interface CacheEntryBaseParams {
} }
export interface CacheEntryParams extends CacheEntryBaseParams { export interface CacheEntryParams extends CacheEntryBaseParams {
key: string cacheId: string
version: string
} }
type CacheValueMap = Map<string, unknown> type VersionedCacheEntry = {
version: string
value: unknown
}
type CacheValueMap = Map<string, VersionedCacheEntry>
type CacheScopeMap = Map<string, CacheValueMap> type CacheScopeMap = Map<string, CacheValueMap>
type CacheSessionMap = Map<string, CacheScopeMap> type CacheSessionMap = Map<string, CacheScopeMap>
@@ -83,18 +89,22 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
if (value === undefined) { if (value === undefined) {
const existingMap = getScopeValueMap(params, false) const existingMap = getScopeValueMap(params, false)
existingMap?.delete(params.key) existingMap?.delete(params.cacheId)
cleanupHierarchy(instanceKey, sessionKey, params.scope) cleanupHierarchy(instanceKey, sessionKey, params.scope)
return return
} }
const scopeEntries = getScopeValueMap(params, true) const scopeEntries = getScopeValueMap(params, true)
scopeEntries?.set(params.key, value) scopeEntries?.set(params.cacheId, { version: params.version, value })
} }
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined { export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
const scopeEntries = getScopeValueMap(params, false) 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 { export function clearCacheScope(params: CacheEntryBaseParams): void {

View File

@@ -18,8 +18,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle
const instanceId = normalizeId(resolveValue(params.instanceId)) const instanceId = normalizeId(resolveValue(params.instanceId))
const sessionId = normalizeId(resolveValue(params.sessionId)) const sessionId = normalizeId(resolveValue(params.sessionId))
const scope = resolveValue(params.scope) const scope = resolveValue(params.scope)
const key = resolveValue(params.key) const cacheId = resolveValue(params.cacheId)
return { instanceId, sessionId, scope, key } const version = String(resolveValue(params.version))
return { instanceId, sessionId, scope, cacheId, version }
}) })
const scopeParams = createMemo(() => { const scopeParams = createMemo(() => {
@@ -73,7 +74,8 @@ interface UseGlobalCacheParams {
instanceId?: MaybeAccessor<string | undefined> instanceId?: MaybeAccessor<string | undefined>
sessionId?: MaybeAccessor<string | undefined> sessionId?: MaybeAccessor<string | undefined>
scope: MaybeAccessor<string> scope: MaybeAccessor<string>
key: MaybeAccessor<string> cacheId: MaybeAccessor<string>
version: MaybeAccessor<string | number>
} }
interface GlobalCacheHandle { interface GlobalCacheHandle {