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 { 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<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) {
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<RenderCache>()
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) {

View File

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

View File

@@ -240,29 +240,33 @@ export default function ToolCall(props: ToolCallProps) {
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({
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<RenderCache>()
if (cached) {
markdownPart.renderCache = cached
}
const handleMarkdownRendered = () => {
markdownCache.set(markdownPart.renderCache)
handleScrollRendered()
props.onContentRendered?.()
}
@@ -751,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<Markdown
part={markdownPart}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered}

View File

@@ -5,10 +5,16 @@ export interface 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 CacheSessionMap = Map<string, CacheScopeMap>
@@ -83,18 +89,22 @@ export function setCacheEntry<T>(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<T>(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 {

View File

@@ -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<string | undefined>
sessionId?: MaybeAccessor<string | undefined>
scope: MaybeAccessor<string>
key: MaybeAccessor<string>
cacheId: MaybeAccessor<string>
version: MaybeAccessor<string | number>
}
interface GlobalCacheHandle {