fix(app): make session navigation stable and fast (#33569)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
abcfeb380b
commit
a4551a94b4
29 changed files with 1028 additions and 202 deletions
78
packages/ui/src/components/markdown-cache.tsx
Normal file
78
packages/ui/src/components/markdown-cache.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { project } from "./markdown-stream"
|
||||
|
||||
export type MarkdownCacheEntry = {
|
||||
raw: string
|
||||
hash: string
|
||||
html: string
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, MarkdownCacheEntry>()
|
||||
const config = {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
FORBID_TAGS: ["style"],
|
||||
FORBID_CONTENTS: ["style", "script"],
|
||||
ADD_TAGS: ["svg", "path"],
|
||||
ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"],
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
if (node.target !== "_blank") return
|
||||
|
||||
const rel = node.getAttribute("rel") ?? ""
|
||||
const set = new Set(rel.split(/\s+/).filter(Boolean))
|
||||
set.add("noopener")
|
||||
set.add("noreferrer")
|
||||
node.setAttribute("rel", Array.from(set).join(" "))
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizeMarkdown(html: string) {
|
||||
if (!DOMPurify.isSupported) return ""
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
export function getCachedMarkdown(key: string) {
|
||||
return cache.get(key)
|
||||
}
|
||||
|
||||
export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
|
||||
if (cache.size <= max) return
|
||||
|
||||
const first = cache.keys().next().value
|
||||
if (!first) return
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
export async function preloadMarkdown(
|
||||
text: string,
|
||||
cacheKey: string,
|
||||
parser: { parse(text: string): string | Promise<string> },
|
||||
) {
|
||||
await Promise.all(
|
||||
project(undefined, text, false).blocks.map(async (block, index) => {
|
||||
if (block.mode === "code") return
|
||||
const key = `${cacheKey}:${index}:${block.mode}`
|
||||
const cached = getCachedMarkdown(key)
|
||||
if (cached?.raw === block.raw) {
|
||||
touchCachedMarkdown(key, cached)
|
||||
return
|
||||
}
|
||||
const hash = checksum(block.raw)
|
||||
if (!hash) return
|
||||
touchCachedMarkdown(key, {
|
||||
raw: block.raw,
|
||||
hash,
|
||||
html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
18
packages/ui/src/components/markdown-preload.test.ts
Normal file
18
packages/ui/src/components/markdown-preload.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { preloadMarkdown } from "./markdown-cache"
|
||||
|
||||
test("preloads completed markdown into the render cache", async () => {
|
||||
const parsed: string[] = []
|
||||
const parser = {
|
||||
parse(text: string) {
|
||||
parsed.push(text)
|
||||
return `<p>${text}</p>`
|
||||
},
|
||||
}
|
||||
const key = `markdown-preload-${crypto.randomUUID()}`
|
||||
|
||||
await preloadMarkdown("prepared response", key, parser)
|
||||
await preloadMarkdown("prepared response", key, parser)
|
||||
|
||||
expect(parsed).toEqual(["prepared response"])
|
||||
})
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useMarked } from "../context/marked"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import DOMPurify from "dompurify"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
|
|
@ -25,15 +24,10 @@ import {
|
|||
} from "./markdown-worker"
|
||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||
|
||||
type Entry = {
|
||||
raw: string
|
||||
hash: string
|
||||
html: string
|
||||
}
|
||||
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||
|
||||
type RenderedBlock =
|
||||
| (Entry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
| {
|
||||
key: string
|
||||
mode: "code"
|
||||
|
|
@ -51,42 +45,13 @@ type RenderResult = {
|
|||
blocks: RenderedBlock[]
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, Entry>()
|
||||
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
if (node.target !== "_blank") return
|
||||
|
||||
const rel = node.getAttribute("rel") ?? ""
|
||||
const set = new Set(rel.split(/\s+/).filter(Boolean))
|
||||
set.add("noopener")
|
||||
set.add("noreferrer")
|
||||
node.setAttribute("rel", Array.from(set).join(" "))
|
||||
})
|
||||
}
|
||||
|
||||
const config = {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
FORBID_TAGS: ["style"],
|
||||
FORBID_CONTENTS: ["style", "script"],
|
||||
ADD_TAGS: ["svg", "path"],
|
||||
ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"],
|
||||
}
|
||||
|
||||
const iconPaths = {
|
||||
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
||||
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
||||
}
|
||||
|
||||
function sanitize(html: string) {
|
||||
if (!DOMPurify.isSupported) return ""
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
|
|
@ -283,17 +248,6 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|||
}
|
||||
}
|
||||
|
||||
function touch(key: string, value: Entry) {
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
|
||||
if (cache.size <= max) return
|
||||
|
||||
const first = cache.keys().next().value
|
||||
if (!first) return
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
|
||||
if (!text) return { text, blocks: [] }
|
||||
const base = key ?? checksum(text)
|
||||
|
|
@ -301,7 +255,7 @@ function initialResult(text: string, key: string | undefined, projection: Projec
|
|||
const blocks = projection.blocks.flatMap((block, index) => {
|
||||
if (block.mode === "code") return []
|
||||
const cacheKey = `${base}:${index}:${block.mode}`
|
||||
const cached = cache.get(cacheKey)
|
||||
const cached = getCachedMarkdown(cacheKey)
|
||||
if (cached?.raw !== block.raw) return []
|
||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
||||
})
|
||||
|
|
@ -387,16 +341,16 @@ export function Markdown(
|
|||
}
|
||||
|
||||
if (key) {
|
||||
const cached = cache.get(key)
|
||||
const cached = getCachedMarkdown(key)
|
||||
if (cached?.raw === block.raw) {
|
||||
touch(key, cached)
|
||||
touchCachedMarkdown(key, cached)
|
||||
return { key: blockKey, mode: block.mode, ...cached }
|
||||
}
|
||||
}
|
||||
|
||||
const hash = checksum(block.raw)
|
||||
const safe = sanitize(await Promise.resolve(marked.parse(block.src)))
|
||||
if (key && hash) touch(key, { raw: block.raw, hash, html: safe })
|
||||
const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
|
||||
if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
|
||||
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue