From 25be29731f9d6df58d783bc43de033f7ecac4378 Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Fri, 21 Aug 2026 13:59:13 +0800 Subject: [PATCH] perf(ui): reuse warm markdown blocks --- .../chat/MarkdownRendererImpl.test.ts | 457 +++++++++++++++++- .../components/chat/MarkdownRendererImpl.tsx | 96 +++- .../chat/markdown/markdownCore.test.ts | 44 ++ .../components/chat/markdown/markdownCore.ts | 28 +- 4 files changed, 592 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts index 8d48fd8a..b9934615 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -1,9 +1,366 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, mock, test } from 'bun:test'; import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser'; const parse = (value: string): ParsedFileReference | null => parseFileReference(value); +type FakeElement = { + childNodes: FakeElement[]; + children: FakeElement[]; + parentNode: FakeElement | null; + attributes: Map; + style: { display: string; setProperty: () => void }; + innerHTML: string; + setAttribute: (name: string, value: string) => void; + getAttribute: (name: string) => string | null; + appendChild: (child: FakeElement) => FakeElement; + replaceWith: (replacement: FakeElement) => void; + remove: () => void; + querySelector: (selector: string) => FakeElement | null; + querySelectorAll: (selector: string) => T[]; + addEventListener: () => void; + removeEventListener: () => void; + contains: (child: FakeElement) => boolean; + isEqualNode: () => boolean; +}; + +type FakeDocument = { createElement: () => FakeElement }; +type FakeJsxProps = { + ref?: { current: FakeElement | null }; + children?: FakeElement | FakeElement[]; + className?: string; + 'data-markdown-content'?: boolean; +}; + +let syncRenderCalls = 0; +let morphCalls = 0; +let decorateCalls = 0; +let mermaidRegistryCreates = 0; +let mermaidRegistryCleanups = 0; +let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null; +let renderedRendererBlocks: Array<{ id: string; html: string }> = []; +let currentContextVersion = 0; +const layoutEffects: Array<() => void> = []; +const passiveEffects: Array<() => void | (() => void)> = []; +let hookCursor = 0; +let hookStates: Array<{ current: null } | undefined> = []; +let activeFakeDocument: FakeDocument | null = null; + +const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => { + let html = ''; + const element: FakeElement = { + childNodes: [], + children: [], + parentNode: null, + attributes: new Map(), + style: { display: '', setProperty: () => undefined }, + get innerHTML() { + return html; + }, + set innerHTML(value: string) { + html = value; + }, + setAttribute(name, value) { + this.attributes.set(name, value); + }, + getAttribute(name) { + return this.attributes.get(name) ?? null; + }, + appendChild(child) { + child.parentNode = this; + this.childNodes.push(child); + this.children.push(child); + return child; + }, + replaceWith(replacement) { + if (!this.parentNode) return; + const parent = this.parentNode; + const index = parent.children.indexOf(this); + if (index < 0) return; + replacement.parentNode = parent; + parent.children[index] = replacement; + parent.childNodes[index] = replacement; + this.parentNode = null; + }, + remove() { + if (!this.parentNode) return; + const parent = this.parentNode; + parent.children = parent.children.filter((child) => child !== this); + parent.childNodes = parent.childNodes.filter((child) => child !== this); + this.parentNode = null; + }, + querySelector(selector) { + if (selector === '[data-markdown-content]') { + return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null; + } + if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) { + return this; + } + for (const child of this.children) { + const match = child.querySelector(selector); + if (match) return match; + } + return null; + }, + querySelectorAll: () => [], + addEventListener: () => undefined, + removeEventListener: () => undefined, + contains(child) { + return child === this || this.children.some((candidate) => candidate.contains(child)); + }, + isEqualNode: () => false, + }; + return element; +}; + +const installRendererDom = () => { + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver'); + const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) }; + activeFakeDocument = documentStub; + Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + matchMedia: () => ({ matches: false }), + setTimeout, + clearTimeout, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0), + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + disconnect() {} + }, + }); + return () => { + if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); + else Reflect.deleteProperty(globalThis, 'document'); + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else Reflect.deleteProperty(globalThis, 'window'); + if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver); + else Reflect.deleteProperty(globalThis, 'MutationObserver'); + activeFakeDocument = null; + }; +}; + +const rendererThemes = [{ + metadata: { id: 'renderer-test' }, + colors: { + surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' }, + interactive: { border: '#ccc' }, + primary: { base: '#00f' }, + }, +}, { + metadata: { id: 'renderer-test-next' }, + colors: { + surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' }, + interactive: { border: '#bbb' }, + primary: { base: '#f00' }, + }, +}]; +let rendererThemeIndex = 0; +const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0]; +const rendererUiState = { + codeBlockLineWrap: false, + mermaidRenderingMode: 'svg', + setCodeBlockLineWrap: () => undefined, + openContextPreview: () => undefined, +}; + +const fakeReact = { + useCallback: (callback: T): T => { + hookCursor += 1; + return callback; + }, + useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); }, + useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); }, + useMemo: (factory: () => T): T => { + hookCursor += 1; + return factory(); + }, + useRef: (current: T) => { + const index = hookCursor; + hookCursor += 1; + if (!hookStates[index]) hookStates[index] = { current: null }; + // SAFETY: this test hook preserves one mutable ref slot per hook index. + return hookStates[index] as { current: T }; + }, + memo: (component: T): T => component, +}; + +const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => { + const ref = props?.ref; + // SAFETY: the renderer test installs the typed fake document before JSX is + // evaluated; this branch only supplies its fake element factory. + const fakeDocument = activeFakeDocument; + if (!fakeDocument) throw new Error('Renderer fake document is not installed'); + const element = ref?.current ?? makeFakeElement(fakeDocument); + if (!ref?.current) { + element.childNodes.length = 0; + element.children.length = 0; + } + if (props) { + if (ref) ref.current = element; + if (props.className) element.setAttribute('class', props.className); + if (props['data-markdown-content']) element.setAttribute('data-markdown-content', ''); + } + const jsxChildren = props?.children; + const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren]; + for (const child of allChildren) { + if (child) element.appendChild(child); + } + return element; +}; + +mock.module('react', () => ({ default: fakeReact })); +mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' })); +mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' })); +mock.module('beautiful-mermaid', () => ({ + renderMermaidASCII: () => '', + renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg, +})); +mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') })); +mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) })); +mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) })); +mock.module('@/lib/url', () => ({ + getUrlScheme: () => null, + isAppLinkUrl: () => false, + isExternalHttpUrl: () => false, + openConfirmedAppLinkUrl: async () => false, + openExternalUrl: async () => undefined, +})); +mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) })); +mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() })); +mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children })); +type RendererUiSelectorResult = boolean | string | (() => void); +const fakeUseUIStore = Object.assign( + (selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState), + { getState: () => rendererUiState }, +); +mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore })); +mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null })); +mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) })); +mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false })); +mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false })); +mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined })); +mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' })); +mock.module('./markdown/markdownCore', () => ({ + getCachedMarkdownBlocks: () => cachedRendererBlocks, + renderMarkdownBlocks: async () => renderedRendererBlocks, + renderMarkdownSync: () => { + syncRenderCalls += 1; + return '

cold

'; + }, +})); +mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined })); +mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) })); +type TestDecorateContext = { + labels: { copy: string }; + codeBlockLineWrap: boolean; + renderMermaid: (source: string) => { svg?: string }; +}; +mock.module('./markdown/decorate', () => ({ + attachMarkdownInteractions: () => () => undefined, + applyMarkdownCodeBlockWrapState: () => undefined, + decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => { + decorateCalls += 1; + if (root.getAttribute('data-test-decoration-marker') === 'true') return; + root.setAttribute('data-test-decoration-marker', 'true'); + root.setAttribute( + 'data-test-decoration', + `${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`, + ); + }, + getMarkdownCodeText: () => '', +})); +mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null })); +mock.module('./markdown/mermaidViewer', () => ({ + createMermaidViewerRegistry: () => { + mermaidRegistryCreates += 1; + return { + refresh: () => undefined, + cleanup: () => { mermaidRegistryCleanups += 1; }, + }; + }, + MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]', + shouldRefreshMermaidViewers: (container: Pick) => container.querySelector('[data-markdown="mermaid-block"]') !== null, +})); +mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined })); +mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } })); + +const { MarkdownRenderer } = await import('./MarkdownRendererImpl'); + +const resetRendererTestState = () => { + cachedRendererBlocks = null; + renderedRendererBlocks = []; + syncRenderCalls = 0; + morphCalls = 0; + decorateCalls = 0; + mermaidRegistryCreates = 0; + mermaidRegistryCleanups = 0; + hookCursor = 0; + hookStates = []; + layoutEffects.length = 0; + passiveEffects.length = 0; + currentContextVersion = 0; + rendererThemeIndex = 0; + rendererUiState.codeBlockLineWrap = false; +}; + +const beginRendererRender = () => { + hookCursor = 0; + return renderMarkdownForTest(); +}; + +const rendererRoot = (value: ReturnType): FakeElement => { + if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) { + throw new Error('Renderer test did not return its fake JSX root'); + } + // SAFETY: the structural check confirms this ReactNode is the object + // returned by the mocked JSX runtime. + const candidate = value as object; + // SAFETY: the mocked JSX runtime creates the complete FakeElement shape. + return candidate as FakeElement; +}; + +const runRendererLayoutEffects = () => { + const pending = layoutEffects.splice(0); + for (const effect of pending) effect(); +}; + +const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect()); + +const findBlock = (root: FakeElement, id: string): FakeElement | null => { + if (root.getAttribute('data-md-id') === id) return root; + for (const child of root.children) { + const match = findBlock(child, id); + if (match) return match; + } + return null; +}; + +const renderMarkdownForTest = () => MarkdownRenderer({ + content: 'cached markdown', + messageId: 'message-1', + isAnimated: false, + isStreaming: false, +}); + +const withRendererDom = async (run: () => void | Promise): Promise => { + const restoreDom = installRendererDom(); + const previousThemeIndex = rendererThemeIndex; + try { + await run(); + } finally { + rendererThemeIndex = previousThemeIndex; + restoreDom(); + } +}; + describe('parseFileReference', () => { test('returns null for empty or whitespace input', () => { expect(parse('')).toBeNull(); @@ -72,11 +429,7 @@ describe('parseFileReference', () => { }); test('preserves line:col form (does not interpret as range)', () => { - expect(parse('src/foo.ts:42:8')).toEqual({ - path: 'src/foo.ts', - line: 42, - column: 8, - }); + expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 }); }); test('preserves hash form', () => { @@ -110,3 +463,95 @@ describe('localPathFromFileUrl', () => { expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull(); }); }); + +describe('MarkdownRenderer warm settled path', () => { + test('installs cached blocks without sync fallback and skips same-ID morph', async () => { + await withRendererDom(async () => { + resetRendererTestState(); + cachedRendererBlocks = [{ id: 'full:cached', html: '

cached

' }]; + renderedRendererBlocks = cachedRendererBlocks; + syncRenderCalls = 0; + morphCalls = 0; + decorateCalls = 0; + + // SAFETY: the test JSX adapter returns the fake element assigned to the + // renderer container ref and exposes the DOM members used below. + const root = rendererRoot(beginRendererRender()); + runRendererLayoutEffects(); + expect(syncRenderCalls).toBe(0); + const block = findBlock(root, 'full:cached'); + expect(block).not.toBeNull(); + expect(block?.innerHTML).toBe('

cached

'); + expect(block?.getAttribute('data-md-block')).toBe(''); + expect(block?.getAttribute('data-md-id')).toBe('full:cached'); + expect(block?.style.display).toBe('contents'); + expect(decorateCalls).toBe(1); + + runRendererPassiveEffects(); + await Promise.resolve(); + expect(morphCalls).toBe(0); + }); + }); + + test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => { + return withRendererDom(() => { + resetRendererTestState(); + const mermaidHtml = '
'; + cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }]; + renderedRendererBlocks = cachedRendererBlocks; + mermaidRegistryCreates = 0; + mermaidRegistryCleanups = 0; + morphCalls = 0; + + const root = rendererRoot(beginRendererRender()); + runRendererLayoutEffects(); + expect(mermaidRegistryCreates).toBe(1); + const cleanups = runRendererPassiveEffects(); + for (const cleanup of cleanups) cleanup?.(); + expect(mermaidRegistryCleanups).toBe(1); + + beginRendererRender(); + runRendererLayoutEffects(); + expect(mermaidRegistryCreates).toBe(2); + expect(findBlock(root, 'full:mermaid')).not.toBeNull(); + expect(morphCalls).toBe(0); + }); + }); + + test('redecorates a same-ID block when decoration context changes before async completion', async () => { + await withRendererDom(async () => { + resetRendererTestState(); + cachedRendererBlocks = [{ + id: 'full:context', + html: '

cached

', + }]; + renderedRendererBlocks = cachedRendererBlocks; + + const root = rendererRoot(beginRendererRender()); + runRendererLayoutEffects(); + const block = findBlock(root, 'full:context'); + const firstDecorationId = block?.getAttribute('data-md-decoration-id'); + expect(firstDecorationId).not.toBeNull(); + const firstDecorateCalls = decorateCalls; + + rendererThemeIndex = 1; + currentContextVersion = 1; + rendererUiState.codeBlockLineWrap = true; + beginRendererRender(); + runRendererLayoutEffects(); + runRendererPassiveEffects(); + await Promise.resolve(); + + expect(decorateCalls).toBeGreaterThan(firstDecorateCalls); + expect(syncRenderCalls).toBe(0); + expect(morphCalls).toBe(0); + const updatedBlock = findBlock(root, 'full:context'); + expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId); + expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee'); + expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true'); + expect(mermaidRegistryCleanups).toBeGreaterThan(0); + expect(mermaidRegistryCreates).toBeGreaterThan(1); + }); + }); + +}); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index b8af3e03..ea9e3ce3 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -20,7 +20,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; -import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore'; +import { + getCachedMarkdownBlocks, + renderMarkdownBlocks, + renderMarkdownSync, + type MarkdownImageMode, +} from './markdown/markdownCore'; import { ensureMarkdownShikiTheme } from './markdown/markdownTheme'; import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars'; import { @@ -659,6 +664,18 @@ const useMermaidInlineInteractions = ({ // so a stable diagram is laid out once and served from cache thereafter. const MERMAID_RENDER_CACHE = new Map(); const MERMAID_RENDER_CACHE_MAX = 100; +const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id'; +const MARKDOWN_DECORATION_IDS = new WeakMap(); +let nextMarkdownDecorationId = 0; + +const getMarkdownDecorationId = (ctx: DecorateContext): string => { + const existing = MARKDOWN_DECORATION_IDS.get(ctx); + if (existing) return existing; + const id = `decoration-${nextMarkdownDecorationId}`; + nextMarkdownDecorationId += 1; + MARKDOWN_DECORATION_IDS.set(ctx, id); + return id; +}; const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => { const existing = MERMAID_RENDER_CACHE.get(key); @@ -780,25 +797,40 @@ const useMorphdomMarkdown = ({ const container = containerRef.current; const target = container?.querySelector('[data-markdown-content]') ?? container; if (!target) return; + const decorationId = getMarkdownDecorationId(ctx); if (text && target.childNodes.length === 0) { - const block = document.createElement('div'); - block.setAttribute('data-md-block', ''); - // `display:contents` keeps margin-collapsing/spacing identical to a flat - // HTML body — the wrapper exists only for per-block reconciliation. - block.style.display = 'contents'; - block.innerHTML = renderMarkdownSync(text, imageMode); - // Decorate synchronously too: wrap code blocks in their framed card, - // mark inline code, build table controls, etc. The async pass re-decorates - // its own DOM before morphing, so without this the first paint shows bare - //
/tables that "snap" into their decorated form a tick later. Matching
-      // the structure here keeps the async morph to syntax colors only.
-      decorateMarkdown(block, ctx);
-      target.appendChild(block);
-      if (shouldRefreshMermaidViewers(block)) {
-        refreshMermaidViewers();
+      const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
+      if (cachedBlocks) {
+        let hasMermaidBlock = false;
+        for (const cachedBlock of cachedBlocks) {
+          const block = document.createElement('div');
+          block.setAttribute('data-md-block', '');
+          block.style.display = 'contents';
+          block.innerHTML = cachedBlock.html;
+          decorateMarkdown(block, ctx);
+          block.setAttribute('data-md-id', cachedBlock.id);
+          block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+          hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
+          target.appendChild(block);
+        }
+        if (hasMermaidBlock) refreshMermaidViewers();
+      } else {
+        const block = document.createElement('div');
+        block.setAttribute('data-md-block', '');
+        block.style.display = 'contents';
+        block.innerHTML = renderMarkdownSync(text, imageMode);
+        decorateMarkdown(block, ctx);
+        block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+        target.appendChild(block);
+        if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
       }
+    } else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
+      // StrictMode re-runs this setup after the cleanup probe. The DOM remains,
+      // but the viewer registry does not, so recreate it without reinstalling
+      // or re-decorating ordinary blocks.
+      refreshMermaidViewers();
     }
-  }, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
+  }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
 
   React.useEffect(() => () => {
     mermaidViewerRef.current?.cleanup();
@@ -810,14 +842,12 @@ const useMorphdomMarkdown = ({
     if (!container) return;
     const target = container.querySelector('[data-markdown-content]') ?? container;
     let active = true;
+    const decorationId = getMarkdownDecorationId(ctx);
 
     void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
       if (!active) return;
       const existing = Array.from(target.children) as HTMLElement[];
 
-      // Reconcile per block: only re-morph blocks whose content changed, leaving
-      // stable leading blocks untouched. Keeps per-stream-step DOM work bounded
-      // to the trailing (growing) block instead of the whole message.
       blocks.forEach((block, index) => {
         let el = existing[index];
         if (!el) {
@@ -826,7 +856,28 @@ const useMorphdomMarkdown = ({
           el.style.display = 'contents';
           target.appendChild(el);
         }
-        if (el.getAttribute('data-md-id') === block.id) return;
+        if (el.getAttribute('data-md-id') === block.id) {
+          if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
+            const hasMermaidBlock = shouldRefreshMermaidViewers(el);
+            if (hasMermaidBlock) {
+              mermaidViewerRef.current?.cleanup();
+              mermaidViewerRef.current = null;
+            }
+            const replacement = document.createElement('div');
+            replacement.setAttribute('data-md-block', '');
+            replacement.style.display = 'contents';
+            replacement.innerHTML = block.html;
+            decorateMarkdown(replacement, ctx);
+            replacement.setAttribute('data-md-id', block.id);
+            replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+            el.replaceWith(replacement);
+            if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
+          }
+          if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
+            refreshMermaidViewers();
+          }
+          return;
+        }
 
         const temp = document.createElement('div');
         temp.innerHTML = block.html;
@@ -838,12 +889,12 @@ const useMorphdomMarkdown = ({
           onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
         });
         el.setAttribute('data-md-id', block.id);
+        el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
         if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
           refreshMermaidViewers();
         }
       });
 
-      // Remove any trailing block elements no longer present.
       const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
       let removedMermaidBlock = false;
       for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -856,7 +907,6 @@ const useMorphdomMarkdown = ({
       if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
         refreshMermaidViewers();
       }
-
     });
 
     return () => {
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
index 9153250d..e1321496 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
@@ -49,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
 const {
   __markdownImageCandidateCacheForTests,
   extractMarkdownImageCandidates,
+  getCachedMarkdownBlocks,
+  renderMarkdownBlocks,
   renderMarkdownSync,
+  resetMarkdownHtmlCacheForTests,
 } = await import('./markdownCore');
 const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
 
@@ -90,6 +93,47 @@ describe('markdown sanitization', () => {
 
 });
 
+describe('Markdown block cache reads', () => {
+  test('returns all settled blocks synchronously after a full cache hit', async () => {
+    resetMarkdownHtmlCacheForTests();
+    const text = '**cached** settled markdown';
+
+    expect(getCachedMarkdownBlocks(text)).toBeNull();
+    const rendered = await renderMarkdownBlocks(text, false);
+
+    expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
+  });
+
+  test('returns null for a cold or partial settled miss', async () => {
+    resetMarkdownHtmlCacheForTests();
+    const first = 'first settled block';
+    const changed = 'first settled block\n\nsecond settled block';
+
+    await renderMarkdownBlocks(first, false);
+
+    expect(getCachedMarkdownBlocks(changed)).toBeNull();
+  });
+
+  test('keeps image mode identity out of the settled full hit', async () => {
+    resetMarkdownHtmlCacheForTests();
+    const text = '![image](https://example.test/image.png)';
+
+    await renderMarkdownBlocks(text, false, 'inline');
+
+    expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
+    expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
+  });
+
+  test('does not treat streaming live-cache entries as settled full hits', async () => {
+    resetMarkdownHtmlCacheForTests();
+    const text = 'streaming markdown';
+
+    await renderMarkdownBlocks(text, true);
+
+    expect(getCachedMarkdownBlocks(text)).toBeNull();
+  });
+});
+
 describe('Markdown images', () => {
   test('renders assistant images as icon-ready text without loading the source', () => {
     const html = renderMarkdownSync([
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index 822a3168..91943d74 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -548,10 +548,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
   live: liveBlockCache.size,
 });
 
-const parseBlock = async (
-  block: MarkdownBlock,
-  imageMode: MarkdownImageMode,
-): Promise => {
+/**
+ * Read a settled render synchronously when every block is already in the full
+ * cache. Cache reads retain the existing LRU `get` semantics and do not insert
+ * or expand either cache.
+ */
+export const getCachedMarkdownBlocks = (
+  text: string,
+  imageMode: MarkdownImageMode = 'inline',
+): RenderedBlock[] | null => {
+  if (!text) return [];
+
+  const blocks = streamBlocks(text, false);
+  const rendered: RenderedBlock[] = [];
+  for (const block of blocks) {
+    const contentHash = contentFingerprint(block.raw);
+    const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
+    const html = fullBlockCache.get(id);
+    if (html === undefined) return null;
+    rendered.push({ id, html });
+  }
+  return rendered;
+};
+
+const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => {
   const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
   const parsed = await Promise.resolve(parser.parse(block.src));
   const withMath = renderMathExpressions(parsed);