From ee0f3bb6fd49b12b862d81a2d1ea2bcab6185670 Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 16 Mar 2026 15:29:59 +0100 Subject: [PATCH] perf(ui): defer markdown syntax highlighting to prevent main thread blocking (#7102) Switching sessions with 40+ messages caused a 2.3s UI freeze due to synchronous Shiki/Oniguruma WASM regex tokenization running on the main thread for every code block. Implements two-pass rendering: first pass renders code blocks as plain
 tags (instant), second pass highlights progressively
via setTimeout(0) to keep the UI responsive.

Key implementation details:
- Morphdom guard preserves Shiki-highlighted 
 blocks during streaming
- replaceWithHighlighted() is wrapper-aware - preserves copy button structure
- LRU highlight cache (500 entries) with null-byte key delimiter
- Generation counter + abort signal prevent stale callbacks and race conditions
- data-source-hash stores FNV-1a hash for O(1) content-change detection
- Removed marked-shiki dependency

Original work by @daem0ndev in #6474.
Supersedes #6474 to run CI from an internal branch.

Fixes #6221
---
 bun.lock                                |   1 -
 packages/ui/package.json                |   1 -
 packages/ui/src/components/markdown.tsx |  70 +++++++--
 packages/ui/src/context/marked.tsx      | 191 +++++++++++++++++++++---
 4 files changed, 230 insertions(+), 33 deletions(-)

diff --git a/bun.lock b/bun.lock
index a3a18be5fa2..f4d56ee65ac 100644
--- a/bun.lock
+++ b/bun.lock
@@ -501,7 +501,6 @@
         "luxon": "catalog:",
         "marked": "catalog:",
         "marked-katex-extension": "5.1.6",
-        "marked-shiki": "catalog:",
         "morphdom": "2.7.8",
         "motion": "12.34.5",
         "motion-dom": "12.34.3",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 489bd622c96..3e3d7f1b0d3 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -57,7 +57,6 @@
     "luxon": "catalog:",
     "marked": "catalog:",
     "marked-katex-extension": "5.1.6",
-    "marked-shiki": "catalog:",
     "morphdom": "2.7.8",
     "motion": "12.34.5",
     "motion-dom": "12.34.3",
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index bb41c74efbd..b9e4855ab9d 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,4 +1,4 @@
-import { useMarked } from "../context/marked"
+import { useMarked, deferredHighlight, fnv1a } from "../context/marked"
 import { useI18n } from "../context/i18n"
 import DOMPurify from "dompurify"
 import morphdom from "morphdom"
@@ -258,8 +258,13 @@ export function Markdown(
     { initialValue: "" },
   )
 
-  let copySetupTimer: ReturnType | undefined
   let copyCleanup: (() => void) | undefined
+  // kilocode_change start: generation counter prevents stale deferredHighlight
+  // callbacks from overwriting copyCleanup set by a newer render (issue #6221).
+  // The abort signal cancels the previous in-flight highlight pass so rapid
+  // streaming tokens don't spawn concurrent passes racing on the same DOM nodes.
+  const highlightState = { gen: 0, signal: { aborted: false } }
+  // kilocode_change end
 
   createEffect(() => {
     const container = root()
@@ -274,31 +279,72 @@ export function Markdown(
 
     const temp = document.createElement("div")
     temp.innerHTML = content
-    decorate(temp, {
+    const labels = {
       copy: i18n.t("ui.message.copy"),
       copied: i18n.t("ui.message.copied"),
-    })
+    }
+    decorate(temp, labels)
 
+    // kilocode_change start: morphdom guard for highlighted blocks (issue #6221)
+    // During streaming, morphdom re-runs on every token. Without this guard,
+    // it would revert already-highlighted 
 blocks back to plain code.
     morphdom(container, temp, {
       childrenOnly: true,
       onBeforeElUpdated: (fromEl, toEl) => {
         if (fromEl.isEqualNode(toEl)) return false
+        // Preserve Shiki-highlighted blocks — don't let morphdom revert them
+        // to plain 
 during streaming re-renders.
+        // Note: "shiki" class is on 
 (set by Shiki's codeToHtml output).
+        // We compare data-source-hash (a lightweight FNV-1a hash stored by
+        // deferredHighlight on the highlighted 
) against a hash of the
+        // incoming code text to detect mid-stream content changes: if the code
+        // changed, we let morphdom update so the block can be re-queued for
+        // highlighting with the new content.
+        if (
+          fromEl instanceof HTMLElement &&
+          fromEl.tagName === "PRE" &&
+          fromEl.classList.contains("shiki") &&
+          toEl instanceof HTMLElement &&
+          toEl.tagName === "PRE" &&
+          !toEl.classList.contains("shiki")
+        ) {
+          const fromHash = fromEl.getAttribute("data-source-hash")
+          const toCode = toEl.querySelector("code")?.textContent ?? ""
+          if (fromHash === fnv1a(toCode)) return false
+          // Source changed during streaming — fall through so morphdom replaces
+          // the stale highlighted block with the updated plain block, which will
+          // be re-highlighted on the next deferredHighlight pass.
+        }
         return true
       },
     })
+    // kilocode_change end
 
-    if (copySetupTimer) clearTimeout(copySetupTimer)
-    copySetupTimer = setTimeout(() => {
+    // kilocode_change start: deferred syntax highlighting (issue #6221)
+    // DOM is now painted with plain 
 blocks.
+    // Progressively highlight via setTimeout(0) to avoid blocking.
+    // onComplete re-runs setupCodeCopy since highlighting replaces DOM nodes.
+    // The generation counter ensures a stale in-flight highlight run (from a
+    // previous streaming token) doesn't overwrite the cleanup set by a newer run.
+    // Cancel any in-flight highlight pass before starting a new one — prevents
+    // concurrent passes from racing to replace the same DOM nodes.
+    highlightState.signal.aborted = true
+    const gen = ++highlightState.gen
+    const signal = { aborted: false }
+    highlightState.signal = signal
+    deferredHighlight(container, () => {
+      if (gen !== highlightState.gen) return
       if (copyCleanup) copyCleanup()
-      copyCleanup = setupCodeCopy(container, {
-        copy: i18n.t("ui.message.copy"),
-        copied: i18n.t("ui.message.copied"),
-      })
-    }, 150)
+      copyCleanup = setupCodeCopy(container, labels)
+    }, signal)
+    // kilocode_change end
   })
 
   onCleanup(() => {
-    if (copySetupTimer) clearTimeout(copySetupTimer)
+    // Invalidate any in-flight deferredHighlight callbacks so they don't call
+    // setupCodeCopy on a disconnected container after the component unmounts.
+    highlightState.signal.aborted = true
+    highlightState.gen++
     if (copyCleanup) copyCleanup()
   })
 
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx
index 726f48c8e53..b5970c560fb 100644
--- a/packages/ui/src/context/marked.tsx
+++ b/packages/ui/src/context/marked.tsx
@@ -1,6 +1,6 @@
 import { marked } from "marked"
 import markedKatex from "marked-katex-extension"
-import markedShiki from "marked-shiki"
+
 import katex from "katex"
 import { bundledLanguages, type BundledLanguage } from "shiki"
 import { parseFilePath } from "../file-path" // kilocode_change
@@ -424,6 +424,8 @@ function renderMathExpressions(html: string): string {
     .join("")
 }
 
+// Used only by the native parser path (props.nativeParser) — not the JS parser.
+// The JS parser uses deferredHighlight() instead for non-blocking rendering.
 async function highlightCodeBlocks(html: string): Promise {
   const codeBlockRegex = /
([\s\S]*?)<\/code><\/pre>/g
   const matches = [...html.matchAll(codeBlockRegex)]
@@ -464,10 +466,162 @@ export type NativeMarkdownParser = (markdown: string) => Promise
 
 // kilocode_change: parseFilePath imported from ../file-path
 
+// kilocode_change start: highlight cache for deferred highlighting
+
+/** FNV-1a hash — lightweight alternative to storing full source code in DOM attributes. */
+export function fnv1a(s: string): string {
+  let h = 0x811c9dc5
+  for (let i = 0; i < s.length; i++) {
+    h ^= s.charCodeAt(i)
+    h = Math.imul(h, 0x01000193)
+  }
+  return (h >>> 0).toString(36)
+}
+
+const cache = new Map()
+const CACHE_LIMIT = 500
+
+// Normalize common language aliases before sanitizing, so e.g. "c++" → "cpp"
+// rather than stripping to "c" (which would highlight as the wrong language).
+const LANG_ALIASES: Record = {
+  "c++": "cpp",
+  "c#": "csharp",
+  "f#": "fsharp",
+  "objective-c++": "objective-cpp",
+}
+
+function touchHighlightCache(key: string, value: string) {
+  cache.delete(key)
+  cache.set(key, value)
+  if (cache.size <= CACHE_LIMIT) return
+  const first = cache.keys().next().value
+  if (!first) return
+  cache.delete(first)
+}
+
+function replaceWithHighlighted(block: Element, html: string, sourceHash: string) {
+  const pre = block.parentElement
+  if (!pre || !pre.isConnected) return
+  const temp = document.createElement("div")
+  temp.innerHTML = html
+  const highlighted = temp.firstElementChild
+  if (!highlighted) return
+  // Store a hash of the source code so the morphdom guard in Markdown can detect
+  // mid-stream content changes without keeping the full source in the DOM.
+  highlighted.setAttribute("data-source-hash", sourceHash)
+  // Preserve any wrapper structure (e.g., markdown-code wrapper with copy button)
+  const wrapper = pre.parentElement
+  if (wrapper?.getAttribute("data-component") === "markdown-code") {
+    wrapper.replaceChild(highlighted, pre)
+    return
+  }
+  pre.replaceWith(highlighted)
+}
+
+/**
+ * Progressively highlight unhighlighted 
 blocks inside a container.
+ * Each block is highlighted via setTimeout(0) to yield back to the main thread
+ * between blocks, keeping the UI responsive.
+ *
+ * Blocks marked with data-highlighted are skipped (already processed).
+ * After highlighting, blocks are marked to survive morphdom re-runs during streaming.
+ *
+ * Returns a callback to re-run setupCodeCopy after highlighting completes,
+ * since highlight replaces DOM nodes that may have copy button wrappers.
+ */
+export async function deferredHighlight(
+  container: HTMLElement,
+  onComplete?: () => void,
+  signal?: { aborted: boolean },
+): Promise {
+  const blocks = Array.from(
+    container.querySelectorAll("pre > code[data-lang]:not([data-highlighted])"),
+  )
+  if (blocks.length === 0) {
+    onComplete?.()
+    return
+  }
+
+  const highlighter = await getSharedHighlighter({ themes: ["Kilo"], langs: [] })
+
+  for (const block of blocks) {
+    // Short-circuit if the container is unmounted or the caller cancelled this run
+    // (e.g., a newer streaming token triggered a fresh deferredHighlight call).
+    if (!container.isConnected || signal?.aborted) break
+
+    const lang = block.getAttribute("data-lang") || "text"
+    const code = block.textContent ?? ""
+    if (!code) continue
+
+    const cacheKey = `${lang}\0${code}`
+    const codeHash = fnv1a(code)
+    const cached = cache.get(cacheKey)
+    if (cached) {
+      touchHighlightCache(cacheKey, cached) // refresh LRU position on hit
+      replaceWithHighlighted(block, cached, codeHash)
+      continue
+    }
+
+    await new Promise((resolve) => {
+      setTimeout(() => {
+        // Re-check inside the timer callback — signal may have been aborted while
+        // this task was queued in the event loop waiting to run.
+        if (!block.isConnected || signal?.aborted) {
+          resolve()
+          return
+        }
+        try {
+          const language = lang in bundledLanguages ? lang : "text"
+          const highlight = () => {
+            // Re-check after async loadLanguage — block may have been replaced
+            // or signal aborted while the language bundle was being fetched.
+            if (!block.isConnected || signal?.aborted) {
+              resolve()
+              return
+            }
+            const html = highlighter.codeToHtml(code, { lang: language, theme: "Kilo", tabindex: false })
+            touchHighlightCache(cacheKey, html)
+            // Note: data-highlighted is NOT set on `block` here because
+            // replaceWithHighlighted replaces the parent 
 entirely — the
+            // original  element leaves the DOM immediately after this call.
+            // The new highlighted 
 from Shiki has no
+            // code[data-lang] child, so the querySelectorAll selector won't
+            // pick it up on subsequent deferredHighlight passes.
+            replaceWithHighlighted(block, html, codeHash)
+            resolve()
+          }
+          if (!highlighter.getLoadedLanguages().includes(language)) {
+            highlighter.loadLanguage(language as BundledLanguage).then(highlight).catch((err) => {
+              console.warn("Failed to load language for highlighting", language, err)
+              resolve()
+            })
+            return
+          }
+          highlight()
+        } catch (err) {
+          console.warn("Deferred highlight failed", lang, err)
+          resolve()
+        }
+      }, 0)
+    })
+  }
+
+  // Only fire onComplete if the container is still mounted and the run wasn't
+  // cancelled — avoids calling setupCodeCopy on a disconnected DOM tree.
+  if (container.isConnected && !signal?.aborted) {
+    onComplete?.()
+  }
+}
+// kilocode_change end
+
 export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
   name: "Marked",
   init: (props: { nativeParser?: NativeMarkdownParser }) => {
-    const jsParser = marked.use(
+    // kilocode_change start: two-pass parser — first pass skips Shiki highlighting
+    // to avoid blocking the main thread with Oniguruma WASM regex (issue #6221).
+    // Code blocks render as plain 
 immediately.
+    // The Markdown component calls deferredHighlight() after DOM paint.
+    const parser = marked.use(
       {
         renderer: {
           link({ href, title, text }) {
@@ -484,6 +638,20 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
             }
             return `${text}`
           },
+          code({ text, lang }) {
+            const escaped = text
+              .replace(/&/g, "&")
+              .replace(//g, ">")
+              .replace(/"/g, """)
+              .replace(/'/g, "'")
+            // Normalize aliases (e.g. "c++" → "cpp") before stripping special
+            // chars, so "c++" doesn't become "c" (wrong language highlight).
+            const normalized = lang ? (LANG_ALIASES[lang] ?? lang) : ""
+            const safe = normalized ? normalized.replace(/[^a-zA-Z0-9_-]/g, "") : ""
+            const attr = safe ? ` class="language-${safe}" data-lang="${safe}"` : ' data-lang="text"'
+            return `
${escaped}
` + }, // kilocode_change end }, }, @@ -491,23 +659,8 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( throwOnError: false, nonStandard: true, }), - markedShiki({ - async highlight(code, lang) { - const highlighter = await getSharedHighlighter({ themes: ["Kilo"], langs: [] }) - if (!(lang in bundledLanguages)) { - lang = "text" - } - if (!highlighter.getLoadedLanguages().includes(lang)) { - await highlighter.loadLanguage(lang as BundledLanguage) - } - return highlighter.codeToHtml(code, { - lang: lang || "text", - theme: "Kilo", - tabindex: false, - }) - }, - }), ) + // kilocode_change end if (props.nativeParser) { const nativeParser = props.nativeParser @@ -520,6 +673,6 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( } } - return jsParser + return parser }, })