From 80fd043f618ce04771c8b0ffd9bb535b79de26dd Mon Sep 17 00:00:00 2001 From: kogekiplay <46434871+kogekiplay@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:22:10 +0800 Subject: [PATCH 1/3] fix(memory): share bounded highlighting with cancellable Markdown retries --- patches/README.md | 23 ++ patches/streamdown@2.7.0.patch | 23 ++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 3 + src/components/ai-elements/code-block.tsx | 175 ++------ src/components/ai-elements/message.test.tsx | 11 +- src/components/ai-elements/reasoning.test.tsx | 12 +- .../ai-elements/streamdown-plugins.test.ts | 6 +- .../ai-elements/streamdown-plugins.ts | 9 +- .../completed-turn-streaming-mode.test.tsx | 12 +- src/lib/code-highlighter-streamdown.test.tsx | 78 ++++ src/lib/code-highlighter.test.ts | 319 +++++++++++++++ src/lib/code-highlighter.ts | 375 ++++++++++++++++++ 13 files changed, 898 insertions(+), 155 deletions(-) create mode 100644 patches/README.md create mode 100644 patches/streamdown@2.7.0.patch create mode 100644 src/lib/code-highlighter-streamdown.test.tsx create mode 100644 src/lib/code-highlighter.test.ts create mode 100644 src/lib/code-highlighter.ts diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000000..ec72e08d0ea --- /dev/null +++ b/patches/README.md @@ -0,0 +1,23 @@ +# Streamdown 2.7.0 highlighting lifecycle + +The `HighlightedCodeBlockBody` effect now supplies an optional `AbortSignal` +to its highlighter plugin and aborts it on a code/language/theme change or +unmount. Plugins that ignore the extra option continue to work unchanged. +Both the executable ESM chunk and the exported TypeScript interface are patched. +Streamdown has no CommonJS entrypoint. + +Codeg uses this lifecycle to remove a superseded Markdown request from the shared +highlighter's callbacks and retry waiters. Work is capped at 16 distinct requests; +there is at most one waiter per live code view, removed at the next streaming +update or unmount. Waiters do not retain token trees or a second cache key. This +avoids accumulating all historical prefixes while still eventually highlighting +every mounted block after a cold-engine burst. Callback-only consumers without +an abort signal have a separate 128-waiter safety limit and fall back to plaintext +beyond it. Codeg's Markdown and file/tool views supply a lifecycle. + +`src/lib/code-highlighter-streamdown.test.tsx` renders the installed Streamdown +package with the real Shiki service, including 24 concurrent blocks. It also +checks that actual component replacement and unmount abort every plugin request. +Service tests exercise saturation, cancelled streaming prefixes, unmounts, +coalescing, and the cache budgets. Remove or revisit this patch when upgrading +Streamdown or after upstream provides an equivalent cancellation lifecycle. diff --git a/patches/streamdown@2.7.0.patch b/patches/streamdown@2.7.0.patch new file mode 100644 index 00000000000..0a514237530 --- /dev/null +++ b/patches/streamdown@2.7.0.patch @@ -0,0 +1,23 @@ +diff --git a/dist/highlighted-body-KQOG7T2V.js b/dist/highlighted-body-KQOG7T2V.js +index b1a24ed4c87a87dda7b1771d645044f41867c208..3b2c001ee60a77f837881f1de4492219cb4c866e 100644 +--- a/dist/highlighted-body-KQOG7T2V.js ++++ b/dist/highlighted-body-KQOG7T2V.js +@@ -1,2 +1,2 @@ + "use client"; +-import {B,f,c}from'./chunk-D32TOXHM.js';import {useContext,useState,useEffect}from'react';import {jsx}from'react/jsx-runtime';var T=({code:s,isIncomplete:l=false,language:e,maxHeight:d,raw:t,className:a,startLine:c$1,lineNumbers:p,...f$1})=>{let{shikiTheme:r}=useContext(B),o=f(),[H,i]=useState(t);return useEffect(()=>{if(!o){i(t);return}let n=true,u=o.highlight({code:s,isIncomplete:l,language:e,themes:r},B=>{n&&i(B);});return u&&i(u),()=>{n=false;}},[s,l,e,r,o,t]),jsx(c,{className:a,language:e,lineNumbers:p,maxHeight:d,result:H,startLine:c$1,...f$1})};export{T as HighlightedCodeBlockBody}; +\ No newline at end of file ++import {B,f,c}from'./chunk-D32TOXHM.js';import {useContext,useState,useEffect}from'react';import {jsx}from'react/jsx-runtime';var T=({code:s,isIncomplete:l=false,language:e,maxHeight:d,raw:t,className:a,startLine:c$1,lineNumbers:p,...f$1})=>{let{shikiTheme:r}=useContext(B),o=f(),[H,i]=useState(t);return useEffect(()=>{if(!o){i(t);return}let n=true,abort=new AbortController(),u=o.highlight({code:s,isIncomplete:l,language:e,themes:r,signal:abort.signal},B=>{n&&i(B);});return u&&i(u),()=>{n=false;abort.abort();}},[s,l,e,r,o,t]),jsx(c,{className:a,language:e,lineNumbers:p,maxHeight:d,result:H,startLine:c$1,...f$1})};export{T as HighlightedCodeBlockBody}; +\ No newline at end of file +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 17a50f652903ae6cf25901dcfd207407352285c8..7f22d9c2b9b7b8328b02eddb14c7182806d2da60 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -182,6 +182,8 @@ interface HighlightResult { + * Options for highlighting code + */ + interface HighlightOptions { ++ /** Aborted when the code view changes or unmounts. */ ++ signal?: AbortSignal; + code: string; + /** + * Whether the code block is still streaming. Its result is superseded by diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bced3b46bb3..736b2e82737 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ overrides: katex: ^0.19.0 ansi-to-react>linkify-it: ^5.0.2 +patchedDependencies: + streamdown@2.7.0: a95674a8c1b7844cde8968d64842e7b842d1368955e880ab223766cb318e13ea + importers: .: @@ -224,7 +227,7 @@ importers: version: 2.0.8(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) streamdown: specifier: ^2.7.0 - version: 2.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.7.0(patch_hash=a95674a8c1b7844cde8968d64842e7b842d1368955e880ab223766cb318e13ea)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -13944,7 +13947,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - streamdown@2.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + streamdown@2.7.0(patch_hash=a95674a8c1b7844cde8968d64842e7b842d1368955e880ab223766cb318e13ea)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: clsx: 2.1.1 hast-util-to-jsx-runtime: 2.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5f7f886596..755b9c3fe43 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,3 +33,6 @@ minimumReleaseAgeExclude: - '@tauri-apps/plugin-dialog@2.8.3' - '@tauri-apps/plugin-opener@2.7.2' - '@tauri-apps/plugin-updater@2.14.0' + +patchedDependencies: + streamdown@2.7.0: patches/streamdown@2.7.0.patch diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index db29ebaf99b..1be545b02b0 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -1,12 +1,7 @@ "use client" import type { ComponentProps, CSSProperties, HTMLAttributes } from "react" -import type { - BundledLanguage, - BundledTheme, - HighlighterGeneric, - ThemedToken, -} from "shiki" +import type { BundledLanguage, ThemedToken, TokensResult } from "shiki" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" @@ -18,6 +13,13 @@ import { SelectValue, } from "@/components/ui/select" import { cn, copyTextToClipboard } from "@/lib/utils" +import { + DEFAULT_CODE_THEMES, + getCachedHighlightedCode, + highlightCode as highlightSharedCode, + rawCodeTokens, + subscribeHighlightedCode, +} from "@/lib/code-highlighter" import { CheckIcon, CopyIcon } from "lucide-react" import { createContext, @@ -118,127 +120,22 @@ const CodeBlockContext = createContext({ code: "", }) -// Highlighter cache (singleton per language) -const highlighterCache = new Map< - string, - Promise> ->() - -// Token cache -const tokensCache = new Map() - -// Subscribers for async token updates -const subscribers = new Map void>>() - -const getTokensCacheKey = (code: string, language: BundledLanguage) => { - const start = code.slice(0, 100) - const end = code.length > 100 ? code.slice(-100) : "" - return `${language}:${code.length}:${start}:${end}` -} - -const getHighlighter = ( - language: BundledLanguage -): Promise> => { - const cached = highlighterCache.get(language) - if (cached) { - return cached - } - - // Load Shiki's engine lazily: a static `import` would pull it (and its - // textmate machinery) into the first-paint bundle for every message list, - // even a text-only conversation. Highlighting is already async with an - // immediate raw-token fallback (see `createRawTokens`), so deferring the - // engine import only extends that existing pre-highlight window slightly. - const highlighterPromise = import("shiki").then(({ createHighlighter }) => - createHighlighter({ - langs: [language], - themes: ["github-light", "github-dark"], - }) - ) - - highlighterCache.set(language, highlighterPromise) - return highlighterPromise -} - -// Create raw tokens for immediate display while highlighting loads -const createRawTokens = (code: string): TokenizedCode => ({ - bg: "transparent", - fg: "inherit", - tokens: code.split("\n").map((line) => - line === "" - ? [] - : [ - { - color: "inherit", - content: line, - } as ThemedToken, - ] - ), +const normalizeTokens = (result: TokensResult): TokenizedCode => ({ + tokens: result.tokens, + fg: result.fg ?? "inherit", + bg: result.bg ?? "transparent", }) -// Synchronous highlight with callback for async results export const highlightCode = ( code: string, language: BundledLanguage, - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) callback?: (result: TokenizedCode) => void ): TokenizedCode | null => { - const tokensCacheKey = getTokensCacheKey(code, language) - - // Return cached result if available - const cached = tokensCache.get(tokensCacheKey) - if (cached) { - return cached - } - - // Subscribe callback if provided - if (callback) { - if (!subscribers.has(tokensCacheKey)) { - subscribers.set(tokensCacheKey, new Set()) - } - subscribers.get(tokensCacheKey)?.add(callback) - } - - // Start highlighting in background - fire-and-forget async pattern - getHighlighter(language) - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) - .then((highlighter) => { - const availableLangs = highlighter.getLoadedLanguages() - const langToUse = availableLangs.includes(language) ? language : "text" - - const result = highlighter.codeToTokens(code, { - lang: langToUse, - themes: { - dark: "github-dark", - light: "github-light", - }, - }) - - const tokenized: TokenizedCode = { - bg: result.bg ?? "transparent", - fg: result.fg ?? "inherit", - tokens: result.tokens, - } - - // Cache the result - tokensCache.set(tokensCacheKey, tokenized) - - // Notify all subscribers - const subs = subscribers.get(tokensCacheKey) - if (subs) { - for (const sub of subs) { - sub(tokenized) - } - subscribers.delete(tokensCacheKey) - } - }) - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) - .catch((error) => { - console.error("Failed to highlight code:", error) - subscribers.delete(tokensCacheKey) - }) - - return null + const result = highlightSharedCode( + { code, language, themes: DEFAULT_CODE_THEMES }, + callback ? (tokens) => callback(normalizeTokens(tokens)) : undefined + ) + return result ? normalizeTokens(result) : null } // Line number styles using CSS counters @@ -391,13 +288,17 @@ export function useHighlightedTokens( language: BundledLanguage ): TokenizedCode { // Memoized raw tokens for immediate display - const rawTokens = useMemo(() => createRawTokens(code), [code]) + const rawTokens = useMemo(() => normalizeTokens(rawCodeTokens(code)), [code]) // Synchronous cached-or-raw value, recomputed when code/language changes - const syncTokenized = useMemo( - () => highlightCode(code, language) ?? rawTokens, - [code, language, rawTokens] - ) + const syncTokenized = useMemo(() => { + const cached = getCachedHighlightedCode({ + code, + language, + themes: DEFAULT_CODE_THEMES, + }) + return cached ? normalizeTokens(cached) : rawTokens + }, [code, language, rawTokens]) // Async highlighted result, tagged with its source code/language const [asyncState, setAsyncState] = useState<{ @@ -406,20 +307,16 @@ export function useHighlightedTokens( tokenized: TokenizedCode } | null>(null) - useEffect(() => { - let cancelled = false - - // Subscribe to async highlighting result - highlightCode(code, language, (result) => { - if (!cancelled) { - setAsyncState({ code, language, tokenized: result }) - } - }) - - return () => { - cancelled = true - } - }, [code, language]) + useEffect( + () => + subscribeHighlightedCode( + { code, language, themes: DEFAULT_CODE_THEMES }, + (result) => { + setAsyncState({ code, language, tokenized: normalizeTokens(result) }) + } + ), + [code, language] + ) // Use async result only if it matches current code/language return asyncState?.code === code && asyncState?.language === language diff --git a/src/components/ai-elements/message.test.tsx b/src/components/ai-elements/message.test.tsx index a0549a4f410..1e386ec43f4 100644 --- a/src/components/ai-elements/message.test.tsx +++ b/src/components/ai-elements/message.test.tsx @@ -35,11 +35,16 @@ vi.mock("@streamdown/math", () => ({ createMathPlugin: () => ({}), })) vi.mock("@streamdown/mermaid", () => ({ mermaid: {} })) -vi.mock("@streamdown/code", () => ({ - code: { +vi.mock("@/lib/code-highlighter", async (importOriginal) => ({ + ...(await importOriginal()), + createCodePlugin: async () => ({ + name: "shiki", + type: "code-highlighter", highlight: vi.fn(), supportsLanguage: vi.fn(() => true), - }, + getSupportedLanguages: () => [], + getThemes: () => ["github-light", "github-dark"], + }), })) vi.mock("@/components/ai-elements/link-safety", () => ({ diff --git a/src/components/ai-elements/reasoning.test.tsx b/src/components/ai-elements/reasoning.test.tsx index c7410937a0e..9a5c16d3eea 100644 --- a/src/components/ai-elements/reasoning.test.tsx +++ b/src/components/ai-elements/reasoning.test.tsx @@ -39,8 +39,16 @@ vi.mock("streamdown", () => ({ vi.mock("@streamdown/cjk", () => ({ cjk: {} })) vi.mock("@streamdown/math", () => ({ createMathPlugin: () => ({}) })) vi.mock("@streamdown/mermaid", () => ({ mermaid: {} })) -vi.mock("@streamdown/code", () => ({ - code: { highlight: vi.fn(), supportsLanguage: vi.fn(() => true) }, +vi.mock("@/lib/code-highlighter", async (importOriginal) => ({ + ...(await importOriginal()), + createCodePlugin: async () => ({ + name: "shiki", + type: "code-highlighter", + highlight: vi.fn(), + supportsLanguage: vi.fn(() => true), + getSupportedLanguages: () => [], + getThemes: () => ["github-light", "github-dark"], + }), })) vi.mock("@/components/ai-elements/link-safety", () => ({ diff --git a/src/components/ai-elements/streamdown-plugins.test.ts b/src/components/ai-elements/streamdown-plugins.test.ts index 961686f01ff..c8afe9d3289 100644 --- a/src/components/ai-elements/streamdown-plugins.test.ts +++ b/src/components/ai-elements/streamdown-plugins.test.ts @@ -26,15 +26,15 @@ vi.mock("@streamdown/cjk", () => ({ })) // The three heavy engines are dynamically imported by `ensure()`; vitest mocks // intercept dynamic `import()` too, so these stand in for shiki/katex/mermaid. -vi.mock("@streamdown/code", () => ({ - code: { +vi.mock("@/lib/code-highlighter", () => ({ + createCodePlugin: async () => ({ name: "shiki", type: "code-highlighter", highlight: mocks.highlight, supportsLanguage: mocks.supportsLanguage, getSupportedLanguages: () => [], getThemes: () => ["light", "dark"], - }, + }), })) vi.mock("@streamdown/math", () => ({ createMathPlugin: mocks.createMathPlugin, diff --git a/src/components/ai-elements/streamdown-plugins.ts b/src/components/ai-elements/streamdown-plugins.ts index 5926f05341b..161403af3d5 100644 --- a/src/components/ai-elements/streamdown-plugins.ts +++ b/src/components/ai-elements/streamdown-plugins.ts @@ -16,7 +16,7 @@ export type HeavyKind = "code" | "math" | "mermaid" // --- Why this module exists -------------------------------------------------- // // The three heavy Streamdown plugins pull in the largest dependencies in the -// whole frontend: `@streamdown/code` → shiki (+ its full grammar/theme index, +// whole frontend: the code plugin → shiki (+ its full grammar/theme index, // ~3.8M), `@streamdown/math` → katex (~4.3M), `@streamdown/mermaid` → mermaid + // cytoscape + the entire d3 galaxy + dagre (~70M+ unpacked). Statically // importing them — as `message.tsx`, `reasoning.tsx`, and `file-workspace-panel` @@ -179,9 +179,10 @@ function ensure(kind: HeavyKind): void { emit() } if (kind === "code") { - import("@streamdown/code") - .then((mod) => { - loaded.code = makeSafeCode(mod.code) + import("@/lib/code-highlighter") + .then((mod) => mod.createCodePlugin()) + .then((code) => { + loaded.code = makeSafeCode(code) }) .catch(() => {}) .finally(settle) diff --git a/src/components/message/completed-turn-streaming-mode.test.tsx b/src/components/message/completed-turn-streaming-mode.test.tsx index 00c5edef93c..d93cc5842f4 100644 --- a/src/components/message/completed-turn-streaming-mode.test.tsx +++ b/src/components/message/completed-turn-streaming-mode.test.tsx @@ -28,8 +28,16 @@ vi.mock("streamdown", () => ({ vi.mock("@streamdown/cjk", () => ({ cjk: {} })) vi.mock("@streamdown/math", () => ({ createMathPlugin: () => ({}) })) vi.mock("@streamdown/mermaid", () => ({ mermaid: {} })) -vi.mock("@streamdown/code", () => ({ - code: { highlight: vi.fn(), supportsLanguage: vi.fn(() => true) }, +vi.mock("@/lib/code-highlighter", async (importOriginal) => ({ + ...(await importOriginal()), + createCodePlugin: async () => ({ + name: "shiki", + type: "code-highlighter", + highlight: vi.fn(), + supportsLanguage: vi.fn(() => true), + getSupportedLanguages: () => [], + getThemes: () => ["github-light", "github-dark"], + }), })) vi.mock("@/components/ai-elements/link-safety", () => ({ diff --git a/src/lib/code-highlighter-streamdown.test.tsx b/src/lib/code-highlighter-streamdown.test.tsx new file mode 100644 index 00000000000..c4155f23c78 --- /dev/null +++ b/src/lib/code-highlighter-streamdown.test.tsx @@ -0,0 +1,78 @@ +import { render, waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { Streamdown, type HighlightOptions } from "streamdown" +import { createCodePlugin, getCodeHighlightStats } from "./code-highlighter" + +function markdown(prefix: string, count: number) { + return Array.from( + { length: count }, + (_, i) => `\`\`\`javascript\nconst ${prefix}${i} = ${i};\n\`\`\`` + ).join("\n\n") +} + +describe("Streamdown highlighting lifecycle", () => { + it("delivers all 24 real Shiki callbacks after concurrent Markdown saturation", async () => { + const shared = await createCodePlugin() + const completed = new Set() + let peak = 0 + let waited = false + const code = { + ...shared, + highlight( + options: HighlightOptions, + callback?: Parameters[1] + ) { + const result = shared.highlight(options, (tokens) => { + expect(tokens.fg).not.toBe("inherit") + completed.add(options.code) + callback?.(tokens) + }) + peak = Math.max(peak, getCodeHighlightStats().inFlight) + waited ||= getCodeHighlightStats().waiting > 0 + return result + }, + } + const view = render( + + {markdown("realShiki", 24)} + + ) + await waitFor(() => expect(completed.size).toBe(24), { timeout: 15_000 }) + expect(peak).toBe(16) + expect(waited).toBe(true) + expect(getCodeHighlightStats().waiting).toBe(0) + for (let i = 0; i < 24; i++) { + expect(view.container.textContent).toContain(`realShiki${i}`) + } + view.unmount() + }, 20_000) + + it("aborts the actual Markdown plugin requests on replacement and unmount", async () => { + const shared = await createCodePlugin() + const requests: HighlightOptions[] = [] + const code = { + ...shared, + highlight(options: HighlightOptions) { + requests.push(options) + return null + }, + } + const view = render( + + {markdown("oldPrefix", 24)} + + ) + await waitFor(() => expect(requests.length).toBe(24)) + const previous = [...requests] + expect(previous.every(({ signal }) => signal && !signal.aborted)).toBe(true) + view.rerender( + + {markdown("newPrefix", 24)} + + ) + await waitFor(() => expect(requests.length).toBe(48)) + expect(previous.every(({ signal }) => signal?.aborted)).toBe(true) + view.unmount() + expect(requests.every(({ signal }) => signal?.aborted)).toBe(true) + }) +}) diff --git a/src/lib/code-highlighter.test.ts b/src/lib/code-highlighter.test.ts new file mode 100644 index 00000000000..076f29abeec --- /dev/null +++ b/src/lib/code-highlighter.test.ts @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { TokensResult } from "shiki" + +const mocks = vi.hoisted(() => ({ + createHighlighter: vi.fn(), + codeToTokens: vi.fn(), + loadLanguage: vi.fn(), + loadTheme: vi.fn(), +})) + +vi.mock("shiki", () => ({ + createHighlighter: mocks.createHighlighter, + bundledLanguagesInfo: [ + { id: "javascript", aliases: ["js"] }, + { id: "typescript", aliases: ["ts"] }, + ], +})) + +let service: typeof import("./code-highlighter") +let engine: { + codeToTokens: typeof mocks.codeToTokens + loadLanguage: typeof mocks.loadLanguage + loadTheme: typeof mocks.loadTheme + getLoadedLanguages: () => string[] + getLoadedThemes: () => string[] +} + +function options(code: string, isIncomplete = false) { + return { + code, + language: "javascript", + themes: ["github-light", "github-dark"] as [string, string], + isIncomplete, + } +} + +function highlight(request: ReturnType) { + return new Promise((resolve) => { + const result = service.highlightCode(request, resolve) + if (result) resolve(result) + }) +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + const languages = new Set() + const themes = new Set() + mocks.loadLanguage.mockImplementation(async (name: string) => { + languages.add(name) + }) + mocks.loadTheme.mockImplementation(async (name: string) => { + themes.add(name) + }) + mocks.codeToTokens.mockImplementation((code: string) => ({ + fg: "#000", + bg: "#fff", + tokens: [[{ content: code, color: "#000", offset: 0 }]], + })) + engine = { + codeToTokens: mocks.codeToTokens, + loadLanguage: mocks.loadLanguage, + loadTheme: mocks.loadTheme, + getLoadedLanguages: () => [...languages], + getLoadedThemes: () => [...themes], + } + mocks.createHighlighter.mockResolvedValue(engine) + service = await import("./code-highlighter") +}) + +describe("shared code highlighting memory", () => { + it("does not retain the prefixes of a growing streaming code block", async () => { + for (let i = 1; i <= 128; i++) { + await highlight(options("const row = true;\n".repeat(i), true)) + } + expect(service.getCodeHighlightStats()).toEqual({ + entries: 0, + estimatedBytes: 0, + inFlight: 0, + waiting: 0, + }) + await highlight(options("const row = true;\n".repeat(128))) + expect(service.getCodeHighlightStats().entries).toBe(1) + }) + + it("bounds completed results by bytes and keeps recently used entries", async () => { + const first = options("first:" + "x".repeat(80_000)) + const hot = options("hot:" + "x".repeat(80_000)) + await highlight(first) + await highlight(hot) + for (let i = 0; i < 80; i++) { + expect(service.getCachedHighlightedCode(hot)).not.toBeNull() + await highlight(options(`${i}:` + "x".repeat(80_000))) + expect( + service.getCodeHighlightStats().estimatedBytes + ).toBeLessThanOrEqual(service.HIGHLIGHT_LIMITS.cacheBytes) + } + expect(service.getCachedHighlightedCode(first)).toBeNull() + expect(service.getCachedHighlightedCode(hot)).not.toBeNull() + }) + + it("also bounds the number of small completed snippets", async () => { + for (let i = 0; i < 200; i++) await highlight(options(`const x = ${i}`)) + expect(service.getCodeHighlightStats().entries).toBe( + service.HIGHLIGHT_LIMITS.cacheEntries + ) + expect(service.getCachedHighlightedCode(options("const x = 0"))).toBeNull() + }) + + it("shares one engine, grammar load and computation between subscribers", async () => { + const a = highlight(options("const x = 1")) + const b = highlight(options("const x = 1")) + expect(await a).toBe(await b) + await highlight({ ...options("const x = 2"), language: "typescript" }) + expect(mocks.createHighlighter).toHaveBeenCalledTimes(1) + expect(mocks.loadLanguage).toHaveBeenCalledTimes(2) + expect(mocks.loadTheme).toHaveBeenCalledTimes(2) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(2) + }) + + it("unsubscribes a code view that unmounts before the engine is ready", async () => { + const listener = vi.fn() + const unsubscribe = service.subscribeHighlightedCode( + options("hello"), + listener + ) + unsubscribe() + await highlight(options("hello")) + expect(listener).not.toHaveBeenCalled() + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + + it("keeps equal-length snippets with matching ends distinct", async () => { + const a = "x".repeat(101) + "A" + "y".repeat(101) + const b = "x".repeat(101) + "B" + "y".repeat(101) + const first = await highlight(options(a)) + const second = await highlight(options(b)) + expect(first.tokens[0][0].content).toBe(a) + expect(second.tokens[0][0].content).toBe(b) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(2) + }) + + it("preserves large code as plaintext without loading the engine", async () => { + const code = "x".repeat(service.HIGHLIGHT_LIMITS.codeLength + 1) + const result = await highlight(options(code)) + expect(result.tokens[0][0].content).toBe(code) + expect(mocks.createHighlighter).not.toHaveBeenCalled() + expect(service.getCodeHighlightStats().entries).toBe(0) + }) + + it("does not cache a token tree larger than the entire budget", async () => { + mocks.codeToTokens.mockReturnValueOnce({ + tokens: [Array(100_000).fill({ content: "x", offset: 0 })], + }) + await highlight(options("many tokens")) + expect(service.getCodeHighlightStats().estimatedBytes).toBe(0) + }) + + it("bounds cold requests and still shows the content when saturated", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const requests = Array.from( + { length: service.HIGHLIGHT_LIMITS.inFlight }, + (_, i) => highlight(options(`cold ${i}`)) + ) + const overflow = await highlight(options("still readable")) + expect(overflow.tokens[0][0].content).toBe("still readable") + expect(service.getCodeHighlightStats().inFlight).toBe( + service.HIGHLIGHT_LIMITS.inFlight + ) + release(engine) + await Promise.all(requests) + await vi.waitFor(() => + expect(service.getCodeHighlightStats().inFlight).toBe(0) + ) + }) + + it("normalizes aliases and uses plaintext for unknown languages", async () => { + const plugin = await service.createCodePlugin() + expect(plugin.supportsLanguage(" JS ")).toBe(true) + expect(plugin.supportsLanguage("##")).toBe(false) + await highlight({ ...options("let x = 1"), language: "js" }) + expect(mocks.codeToTokens).toHaveBeenLastCalledWith( + "let x = 1", + expect.objectContaining({ lang: "javascript" }) + ) + await highlight({ ...options("a log line"), language: "log" }) + expect(mocks.codeToTokens).toHaveBeenLastCalledWith( + "a log line", + expect.objectContaining({ lang: "text" }) + ) + }) + + it("retries all mounted overflow subscribers within the concurrency bound", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const listeners = Array.from({ length: 80 }, () => vi.fn()) + const cleanups = listeners.map((listener, i) => + service.subscribeHighlightedCode(options(`mounted ${i}`), listener) + ) + expect(service.getCodeHighlightStats().inFlight).toBe(16) + expect(service.getCodeHighlightStats().waiting).toBe(64) + // The waiting view remains readable while the engine is cold. + expect(listeners[79].mock.lastCall?.[0].fg).toBe("inherit") + cleanups[79]() + await vi.waitFor(() => + expect(mocks.createHighlighter).toHaveBeenCalledOnce() + ) + release(engine) + await vi.waitFor(() => { + expect(service.getCodeHighlightStats().waiting).toBe(0) + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + for (const listener of listeners.slice(0, 79)) { + expect(listener.mock.lastCall?.[0].fg).toBe("#000") + } + expect(listeners[79]).toHaveBeenCalledTimes(1) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(79) + cleanups.forEach((cleanup) => cleanup()) + }) + + it("retries callback-only Streamdown highlights after saturation", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const callbacks = Array.from({ length: 24 }, () => vi.fn()) + for (const [index, callback] of callbacks.entries()) { + const result = service.highlightCode( + options(`streamdown ${index}`), + callback + ) + if (index < 16) expect(result).toBeNull() + else expect(result?.fg).toBe("inherit") + } + expect(service.getCodeHighlightStats().inFlight).toBe(16) + expect(service.getCodeHighlightStats().waiting).toBe(8) + await vi.waitFor(() => + expect(mocks.createHighlighter).toHaveBeenCalledOnce() + ) + release(engine) + await vi.waitFor(() => { + expect(service.getCodeHighlightStats().waiting).toBe(0) + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + for (const callback of callbacks) expect(callback).toHaveBeenCalledTimes(1) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(24) + }) + + it("drops superseded streaming waiters and cancels queued Markdown on unmount", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const cold = Array.from({ length: 16 }, (_, i) => + highlight(options(`busy ${i}`)) + ) + const listener = vi.fn() + let abort = new AbortController() + for (let i = 0; i < 200; i++) { + abort.abort() + abort = new AbortController() + service.highlightCode( + { ...options(`prefix ${i}`, true), signal: abort.signal }, + listener + ) + expect(service.getCodeHighlightStats().waiting).toBe(1) + } + abort.abort() + expect(service.getCodeHighlightStats().waiting).toBe(0) + await vi.waitFor(() => + expect(mocks.createHighlighter).toHaveBeenCalledOnce() + ) + release(engine) + await Promise.all(cold) + expect(listener).not.toHaveBeenCalled() + expect(mocks.codeToTokens).toHaveBeenCalledTimes(16) + }) + + it("bounds queued legacy callers that have no cancellation lifecycle", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const listener = vi.fn() + for (let i = 0; i < 1000; i++) { + service.highlightCode(options(`detached ${i}`), listener) + } + expect(service.getCodeHighlightStats().inFlight).toBe(16) + expect(service.getCodeHighlightStats().waiting).toBe( + service.HIGHLIGHT_LIMITS.detachedWaiters + ) + await vi.waitFor(() => + expect(mocks.createHighlighter).toHaveBeenCalledOnce() + ) + release(engine) + await vi.waitFor(() => { + expect(service.getCodeHighlightStats().waiting).toBe(0) + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + expect(listener).toHaveBeenCalledTimes( + 16 + service.HIGHLIGHT_LIMITS.detachedWaiters + ) + }) +}) diff --git a/src/lib/code-highlighter.ts b/src/lib/code-highlighter.ts new file mode 100644 index 00000000000..3a46a99f71e --- /dev/null +++ b/src/lib/code-highlighter.ts @@ -0,0 +1,375 @@ +import type { + BundledLanguage, + BundledTheme, + Highlighter, + ThemeRegistrationAny, + TokensResult, +} from "shiki" +import type { + CodeHighlighterPlugin, + HighlightOptions, + ThemeInput, +} from "streamdown" + +type HighlightRequest = Omit & { + language: string + signal?: AbortSignal +} +type Callback = (result: TokensResult) => void + +export const HIGHLIGHT_LIMITS = { + cacheBytes: 16 * 1024 * 1024, + cacheEntries: 128, + inFlight: 16, + // Legacy callers without a lifecycle cannot wait indefinitely in bulk. + detachedWaiters: 128, + codeLength: 100_000, + lines: 5_000, +} as const + +export const DEFAULT_CODE_THEMES: [ThemeInput, ThemeInput] = [ + "github-light", + "github-dark", +] + +const cache = new Map() +const pending = new Map< + string, + { callbacks: Set; cacheResult: boolean; prefetch: boolean } +>() +const loading = new Map>() +// Only mounted subscribers wait here, without token trees or duplicate cache +// keys. Their cleanup removes them when a view unmounts or streaming advances. +const waiting = new Set<() => void>() +let detachedWaiters = 0 +let retryScheduled = false +let cacheBytes = 0 +let shikiPromise: Promise | undefined +let highlighterPromise: Promise | undefined + +function loadShiki() { + shikiPromise ??= import("shiki").catch((error) => { + shikiPromise = undefined + throw error + }) + return shikiPromise +} + +function getHighlighter() { + // One engine and one copy of each theme, shared by Markdown and tool/file + // code views. Importing this module does not eagerly load Shiki. + highlighterPromise ??= loadShiki() + .then(({ createHighlighter }) => + createHighlighter({ langs: [], themes: [] }) + ) + .catch((error) => { + highlighterPromise = undefined + throw error + }) + return highlighterPromise +} + +function once(key: string, load: () => Promise) { + const existing = loading.get(key) + if (existing) return existing + const task = load().finally(() => loading.delete(key)) + loading.set(key, task) + return task +} + +function cacheKey({ code, language, themes }: HighlightRequest) { + // Full content prevents two snippets with equal lengths and ends from + // incorrectly sharing tokens. The key's storage counts against the budget. + return JSON.stringify([language, themes, code]) +} + +function read(key: string) { + const entry = cache.get(key) + if (!entry) return null + cache.delete(key) + cache.set(key, entry) + return entry.result +} + +function remember(key: string, result: TokensResult) { + let bytes = key.length * 2 + 128 + for (const line of result.tokens) { + bytes += 24 + for (const token of line) { + bytes += 192 + token.content.length * 2 + for (const [name, value] of Object.entries(token.htmlStyle ?? {})) { + bytes += 48 + (name.length + String(value).length) * 2 + } + } + } + // A single large result must not evict the entire cache and then exceed it. + if (bytes > HIGHLIGHT_LIMITS.cacheBytes) return + const previous = cache.get(key) + if (previous) { + cacheBytes -= previous.bytes + cache.delete(key) + } + while ( + cache.size >= HIGHLIGHT_LIMITS.cacheEntries || + cacheBytes + bytes > HIGHLIGHT_LIMITS.cacheBytes + ) { + const oldest = cache.entries().next().value + if (!oldest) break + cache.delete(oldest[0]) + cacheBytes -= oldest[1].bytes + } + cache.set(key, { result, bytes }) + cacheBytes += bytes +} + +export function rawCodeTokens(code: string): TokensResult { + return { + fg: "inherit", + bg: "transparent", + tokens: code + .split("\n") + .map((content) => + content ? [{ content, color: "inherit", offset: 0 }] : [] + ), + } +} + +function canHighlight(code: string) { + if (code.length > HIGHLIGHT_LIMITS.codeLength) return false + let lines = 1 + for (let i = 0; i < code.length; i++) { + if (code.charCodeAt(i) === 10 && ++lines > HIGHLIGHT_LIMITS.lines) { + return false + } + } + return true +} + +export function getCachedHighlightedCode(options: HighlightRequest) { + return canHighlight(options.code) ? read(cacheKey(options)) : null +} + +function requestHighlight( + options: HighlightRequest, + callback?: Callback +): TokensResult | null | "busy" { + if (!canHighlight(options.code)) return rawCodeTokens(options.code) + const key = cacheKey(options) + const cached = read(key) + if (cached) return cached + const existing = pending.get(key) + if (existing) { + if (callback) existing.callbacks.add(callback) + else existing.prefetch = true + existing.cacheResult ||= !options.isIncomplete + return null + } + // Bound requests waiting for a cold grammar/engine too, not just results. + if (pending.size >= HIGHLIGHT_LIMITS.inFlight) { + return "busy" + } + const request = { + callbacks: new Set(callback ? [callback] : []), + cacheResult: !options.isIncomplete, + prefetch: !callback, + } + pending.set(key, request) + void (async () => { + let result: TokensResult + try { + const [shiki, highlighter] = await Promise.all([ + loadShiki(), + getHighlighter(), + ]) + const name = options.language.trim().toLowerCase() + const info = shiki.bundledLanguagesInfo.find( + (entry) => entry.id === name || entry.aliases?.includes(name) + ) + const language = info ? (info.id as BundledLanguage) : "text" + // Streamdown deliberately declares permissive structural theme types; + // its custom-theme objects are passed through to Shiki at this boundary. + const themes = options.themes as [ + BundledTheme | ThemeRegistrationAny, + BundledTheme | ThemeRegistrationAny, + ] + await Promise.all([ + ...(language !== "text" && + !highlighter.getLoadedLanguages().includes(language) + ? [ + once(`language:${language}`, () => + highlighter.loadLanguage(language) + ), + ] + : []), + ...themes.map((theme) => { + if ( + typeof theme === "string" && + highlighter.getLoadedThemes().includes(theme) + ) { + return undefined + } + return once(`theme:${JSON.stringify(theme)}`, () => + highlighter.loadTheme(theme) + ) + }), + ]) + // A streaming update or unmount can dispose the last subscriber while + // the engine/grammar is loading. Do not tokenize that obsolete prefix. + if (!request.prefetch && request.callbacks.size === 0) return + result = highlighter.codeToTokens(options.code, { + lang: language, + themes: { light: themes[0], dark: themes[1] }, + }) + // Incomplete blocks are superseded on the next streaming batch. Keeping + // every prefix turns one growing block into quadratic retained tokens. + if (request.cacheResult) remember(key, result) + } catch (error) { + console.error("Failed to highlight code:", error) + result = rawCodeTokens(options.code) + } finally { + pending.delete(key) + scheduleRetries() + } + for (const listener of request.callbacks) { + try { + listener(result) + } catch (error) { + console.error("Failed to deliver highlighted code:", error) + } + } + request.callbacks.clear() + })() + return null +} + +function scheduleRetries() { + if (retryScheduled || waiting.size === 0) return + retryScheduled = true + queueMicrotask(() => { + retryScheduled = false + let remaining = HIGHLIGHT_LIMITS.inFlight + while ( + remaining-- > 0 && + pending.size < HIGHLIGHT_LIMITS.inFlight && + waiting.size > 0 + ) { + const retry = waiting.values().next().value! + waiting.delete(retry) + try { + retry() + } catch (error) { + console.error("Failed to deliver highlighted code:", error) + } + } + if (pending.size < HIGHLIGHT_LIMITS.inFlight) scheduleRetries() + }) +} + +export function highlightCode( + options: HighlightRequest, + callback?: Callback +): TokensResult | null { + return startHighlight(options, callback).result +} + +function startHighlight(options: HighlightRequest, callback?: Callback) { + const signal = options.signal + if (signal?.aborted) { + return { result: rawCodeTokens(options.code), cancel: () => {} } + } + let active = true + let detached = false + const cancel = () => { + active = false + waiting.delete(retry) + if (detached) { + detachedWaiters-- + detached = false + } + signal?.removeEventListener("abort", cancel) + for (const request of pending.values()) request.callbacks.delete(deliver) + } + const deliver: Callback = (result) => { + if (!active) return + cancel() + callback?.(result) + } + const retry = () => { + if (!active) return + const result = requestHighlight(options, deliver) + if (result === "busy") { + waiting.add(retry) + } else if (result) { + deliver(result) + } + } + const result = requestHighlight(options, callback ? deliver : undefined) + if (result === "busy" && callback) { + // Markdown and code views supply an AbortSignal: one waiter belongs to + // one live view, removed on streaming replacement/unmount, and no token + // tree or duplicate key is queued. There is no historical-prefix backlog. + // Keep callback-only callers bounded too; app views use the lifecycle path. + if (signal || detachedWaiters < HIGHLIGHT_LIMITS.detachedWaiters) { + if (!signal) { + detached = true + detachedWaiters++ + } + waiting.add(retry) + } else { + cancel() + } + } + if (result !== null && result !== "busy") cancel() + if (active && callback) + signal?.addEventListener("abort", cancel, { once: true }) + return { + result: result === "busy" ? rawCodeTokens(options.code) : result, + cancel, + } +} + +export function subscribeHighlightedCode( + options: HighlightRequest, + callback: Callback +) { + const abort = new AbortController() + const onAbort = () => abort.abort() + options.signal?.addEventListener("abort", onAbort, { once: true }) + if (options.signal?.aborted) abort.abort() + const subscription = startHighlight( + { ...options, signal: abort.signal }, + callback + ) + if (subscription.result && !abort.signal.aborted) + callback(subscription.result) + return () => { + options.signal?.removeEventListener("abort", onAbort) + abort.abort() + subscription.cancel() + } +} + +export function getCodeHighlightStats() { + return { + entries: cache.size, + estimatedBytes: cacheBytes, + inFlight: pending.size, + waiting: waiting.size, + } +} + +export async function createCodePlugin(): Promise { + const { bundledLanguagesInfo } = await loadShiki() + const languages = new Set( + bundledLanguagesInfo.flatMap(({ id, aliases }) => [id, ...(aliases ?? [])]) + ) + return { + name: "shiki", + type: "code-highlighter", + highlight: highlightCode, + supportsLanguage: (language) => + languages.has(language.trim().toLowerCase()), + getSupportedLanguages: () => bundledLanguagesInfo.map(({ id }) => id), + getThemes: () => DEFAULT_CODE_THEMES, + } +} From cd35a36c7ee358f86685b90056b8fb3e7cc4c1c2 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 10 Nov 2026 17:14:31 +0800 Subject: [PATCH 2/3] fix(memory): copy dependency patches into the Docker build and keep file-view highlighting pnpm-workspace.yaml now lists patches/streamdown@2.7.0.patch, so the Dockerfile's frozen install aborted with ENOENT before patches/ was copied. The shared highlighter's 100k-character plaintext cutoff also dropped highlighting for source files the file view still shows (up to 512 KiB / 5,000 lines); align the cutoff with that view's ceilings. --- Dockerfile | 2 ++ src/lib/code-highlighter.test.ts | 16 ++++++++++++++++ src/lib/code-highlighter.ts | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a010feff896..52867041577 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,8 @@ FROM node:24-alpine AS frontend RUN corepack enable WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# `pnpm-workspace.yaml` applies the dependency patches in patches/ at install. +COPY patches/ ./patches/ RUN pnpm install --frozen-lockfile COPY src/ ./src/ COPY public/ ./public/ diff --git a/src/lib/code-highlighter.test.ts b/src/lib/code-highlighter.test.ts index 076f29abeec..90899f2c621 100644 --- a/src/lib/code-highlighter.test.ts +++ b/src/lib/code-highlighter.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import type { TokensResult } from "shiki" +import { + SOURCE_VIEW_MAX_BYTES, + SOURCE_VIEW_MAX_LINES, +} from "@/components/files/file-document-view" const mocks = vi.hoisted(() => ({ createHighlighter: vi.fn(), @@ -148,6 +152,18 @@ describe("shared code highlighting memory", () => { expect(service.getCodeHighlightStats().entries).toBe(0) }) + it("highlights every file the source view agrees to show", async () => { + const line = "x".repeat( + Math.floor(SOURCE_VIEW_MAX_BYTES / SOURCE_VIEW_MAX_LINES) - 1 + ) + const code = Array(SOURCE_VIEW_MAX_LINES).fill(line).join("\n") + expect(code.length).toBeLessThanOrEqual(SOURCE_VIEW_MAX_BYTES) + expect(code.length).toBeGreaterThan(SOURCE_VIEW_MAX_BYTES - 5_000) + const result = await highlight(options(code)) + expect(result.fg).toBe("#000") + expect(mocks.codeToTokens).toHaveBeenCalledOnce() + }) + it("does not cache a token tree larger than the entire budget", async () => { mocks.codeToTokens.mockReturnValueOnce({ tokens: [Array(100_000).fill({ content: "x", offset: 0 })], diff --git a/src/lib/code-highlighter.ts b/src/lib/code-highlighter.ts index 3a46a99f71e..b41dae01c32 100644 --- a/src/lib/code-highlighter.ts +++ b/src/lib/code-highlighter.ts @@ -23,7 +23,9 @@ export const HIGHLIGHT_LIMITS = { inFlight: 16, // Legacy callers without a lifecycle cannot wait indefinitely in bulk. detachedWaiters: 128, - codeLength: 100_000, + // Same ceilings as the file source view (`SOURCE_VIEW_MAX_BYTES`/`_LINES`), + // so every file it agrees to show is still highlighted. + codeLength: 512 * 1024, lines: 5_000, } as const From 6677de43229518d6e34d355765478219a4173648 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 10 Nov 2026 17:49:05 +0800 Subject: [PATCH 3/3] chore(deps): upgrade streamdown to 2.7.1 and drop unused @streamdown/code The shared highlighter replaced @streamdown/code, so nothing imports it. Streamdown 2.7.1 only renames the highlighted-body chunk; its highlight effect is unchanged, so the AbortSignal patch is carried over verbatim (re-generated against the 2.7.1 tarball). The lockfile is edited to just these packages: a plain install also re-resolved unrelated transitive dependencies (micromark, parse5, overlayscrollbars-react). --- package.json | 3 +-- patches/README.md | 2 +- ...own@2.7.0.patch => streamdown@2.7.1.patch} | 14 +++++------ pnpm-lock.yaml | 25 +++++-------------- pnpm-workspace.yaml | 2 +- 5 files changed, 16 insertions(+), 30 deletions(-) rename patches/{streamdown@2.7.0.patch => streamdown@2.7.1.patch} (77%) diff --git a/package.json b/package.json index 02ed9f8ed42..5d629c99400 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@plantuml/core": "1.2026.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@streamdown/cjk": "^1.0.2", - "@streamdown/code": "^1.0.2", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.3", "@tauri-apps/api": "^2.12.4", @@ -94,7 +93,7 @@ "shiki": "^3.22.0", "smol-toml": "^1.9.1", "sonner": "^2.0.7", - "streamdown": "^2.7.0", + "streamdown": "^2.7.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "use-stick-to-bottom": "^1.1.3", diff --git a/patches/README.md b/patches/README.md index ec72e08d0ea..531b12ac4b6 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,4 +1,4 @@ -# Streamdown 2.7.0 highlighting lifecycle +# Streamdown 2.7.1 highlighting lifecycle The `HighlightedCodeBlockBody` effect now supplies an optional `AbortSignal` to its highlighter plugin and aborts it on a code/language/theme change or diff --git a/patches/streamdown@2.7.0.patch b/patches/streamdown@2.7.1.patch similarity index 77% rename from patches/streamdown@2.7.0.patch rename to patches/streamdown@2.7.1.patch index 0a514237530..91a4416ee57 100644 --- a/patches/streamdown@2.7.0.patch +++ b/patches/streamdown@2.7.1.patch @@ -1,15 +1,15 @@ -diff --git a/dist/highlighted-body-KQOG7T2V.js b/dist/highlighted-body-KQOG7T2V.js -index b1a24ed4c87a87dda7b1771d645044f41867c208..3b2c001ee60a77f837881f1de4492219cb4c866e 100644 ---- a/dist/highlighted-body-KQOG7T2V.js -+++ b/dist/highlighted-body-KQOG7T2V.js +diff --git a/dist/highlighted-body-JLMTFACH.js b/dist/highlighted-body-JLMTFACH.js +index 4aa2fedc6052d65b7cb966ce40786674ec9c9633..edec7aad4b6141c89627b96eedc6d2f2a9aa98fb 100644 +--- a/dist/highlighted-body-JLMTFACH.js ++++ b/dist/highlighted-body-JLMTFACH.js @@ -1,2 +1,2 @@ "use client"; --import {B,f,c}from'./chunk-D32TOXHM.js';import {useContext,useState,useEffect}from'react';import {jsx}from'react/jsx-runtime';var T=({code:s,isIncomplete:l=false,language:e,maxHeight:d,raw:t,className:a,startLine:c$1,lineNumbers:p,...f$1})=>{let{shikiTheme:r}=useContext(B),o=f(),[H,i]=useState(t);return useEffect(()=>{if(!o){i(t);return}let n=true,u=o.highlight({code:s,isIncomplete:l,language:e,themes:r},B=>{n&&i(B);});return u&&i(u),()=>{n=false;}},[s,l,e,r,o,t]),jsx(c,{className:a,language:e,lineNumbers:p,maxHeight:d,result:H,startLine:c$1,...f$1})};export{T as HighlightedCodeBlockBody}; +-import {B,f,c}from'./chunk-7TTQLYRW.js';import {useContext,useState,useEffect}from'react';import {jsx}from'react/jsx-runtime';var T=({code:s,isIncomplete:l=false,language:e,maxHeight:d,raw:t,className:a,startLine:c$1,lineNumbers:p,...f$1})=>{let{shikiTheme:r}=useContext(B),o=f(),[H,i]=useState(t);return useEffect(()=>{if(!o){i(t);return}let n=true,u=o.highlight({code:s,isIncomplete:l,language:e,themes:r},B=>{n&&i(B);});return u&&i(u),()=>{n=false;}},[s,l,e,r,o,t]),jsx(c,{className:a,language:e,lineNumbers:p,maxHeight:d,result:H,startLine:c$1,...f$1})};export{T as HighlightedCodeBlockBody}; \ No newline at end of file -+import {B,f,c}from'./chunk-D32TOXHM.js';import {useContext,useState,useEffect}from'react';import {jsx}from'react/jsx-runtime';var T=({code:s,isIncomplete:l=false,language:e,maxHeight:d,raw:t,className:a,startLine:c$1,lineNumbers:p,...f$1})=>{let{shikiTheme:r}=useContext(B),o=f(),[H,i]=useState(t);return useEffect(()=>{if(!o){i(t);return}let n=true,abort=new AbortController(),u=o.highlight({code:s,isIncomplete:l,language:e,themes:r,signal:abort.signal},B=>{n&&i(B);});return u&&i(u),()=>{n=false;abort.abort();}},[s,l,e,r,o,t]),jsx(c,{className:a,language:e,lineNumbers:p,maxHeight:d,result:H,startLine:c$1,...f$1})};export{T as HighlightedCodeBlockBody}; ++import {B,f,c}from'./chunk-7TTQLYRW.js';import {useContext,useState,useEffect}from'react';import {jsx}from'react/jsx-runtime';var T=({code:s,isIncomplete:l=false,language:e,maxHeight:d,raw:t,className:a,startLine:c$1,lineNumbers:p,...f$1})=>{let{shikiTheme:r}=useContext(B),o=f(),[H,i]=useState(t);return useEffect(()=>{if(!o){i(t);return}let n=true,abort=new AbortController(),u=o.highlight({code:s,isIncomplete:l,language:e,themes:r,signal:abort.signal},B=>{n&&i(B);});return u&&i(u),()=>{n=false;abort.abort();}},[s,l,e,r,o,t]),jsx(c,{className:a,language:e,lineNumbers:p,maxHeight:d,result:H,startLine:c$1,...f$1})};export{T as HighlightedCodeBlockBody}; \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts -index 17a50f652903ae6cf25901dcfd207407352285c8..7f22d9c2b9b7b8328b02eddb14c7182806d2da60 100644 +index d201fee607ae5238f102ed23463967ae6c18ac2c..89731cacb33f834a39947ddbdd39dce5ded7dd7e 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -182,6 +182,8 @@ interface HighlightResult { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736b2e82737..f1a8324e032 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: ansi-to-react>linkify-it: ^5.0.2 patchedDependencies: - streamdown@2.7.0: a95674a8c1b7844cde8968d64842e7b842d1368955e880ab223766cb318e13ea + streamdown@2.7.1: 37dc22ee017cf9114cea238a0a4ea19ff599b2380dcb9b2529014158178b73ea importers: @@ -54,9 +54,6 @@ importers: '@streamdown/cjk': specifier: ^1.0.2 version: 1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5) - '@streamdown/code': - specifier: ^1.0.2 - version: 1.0.2(react@19.2.4) '@streamdown/math': specifier: ^1.0.2 version: 1.0.2(react@19.2.4) @@ -226,8 +223,8 @@ importers: specifier: ^2.0.7 version: 2.0.8(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) streamdown: - specifier: ^2.7.0 - version: 2.7.0(patch_hash=a95674a8c1b7844cde8968d64842e7b842d1368955e880ab223766cb318e13ea)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^2.7.1 + version: 2.7.1(patch_hash=37dc22ee017cf9114cea238a0a4ea19ff599b2380dcb9b2529014158178b73ea)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -2490,11 +2487,6 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - '@streamdown/code@1.0.2': - resolution: {integrity: sha512-QKLS3sC8no5y0YvhGLA+ZjtNhznWU09IvFcjRKgSA35ulckMLw3b5T1ha+o1DaW8BS8l0zceLPFZa3/X9+agWQ==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - '@streamdown/math@1.0.2': resolution: {integrity: sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g==} peerDependencies: @@ -6495,8 +6487,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - streamdown@2.7.0: - resolution: {integrity: sha512-SHYTCveHpRJfcIC8r+5EANczK21R0iNYo4i7wNaLeLeQjEnaVk4xMFhgV4E/mix69BpFSBui1NOESEXKlZfReA==} + streamdown@2.7.1: + resolution: {integrity: sha512-pIz4XOB+e7iyqSJABXC9PoyHifBZVQx/7U0Qo/vdOIERqGXfgEhJxkXENsuWMO8TGXkaQ5ewV9jnliK7q47T8w==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -9258,11 +9250,6 @@ snapshots: - micromark-util-types - unified - '@streamdown/code@1.0.2(react@19.2.4)': - dependencies: - react: 19.2.4 - shiki: 3.22.0 - '@streamdown/math@1.0.2(react@19.2.4)': dependencies: katex: 0.19.0 @@ -13947,7 +13934,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - streamdown@2.7.0(patch_hash=a95674a8c1b7844cde8968d64842e7b842d1368955e880ab223766cb318e13ea)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + streamdown@2.7.1(patch_hash=37dc22ee017cf9114cea238a0a4ea19ff599b2380dcb9b2529014158178b73ea)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: clsx: 2.1.1 hast-util-to-jsx-runtime: 2.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 755b9c3fe43..0bd69a27ff3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,4 +35,4 @@ minimumReleaseAgeExclude: - '@tauri-apps/plugin-updater@2.14.0' patchedDependencies: - streamdown@2.7.0: patches/streamdown@2.7.0.patch + streamdown@2.7.1: patches/streamdown@2.7.1.patch