Parcourir la source

perf(client): defer offscreen diagram previews

yudshj il y a 2 semaines
Parent
commit
34bc53eff8
24 fichiers modifiés avec 772 ajouts et 124 suppressions
  1. 2 2
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
  3. 1 1
      .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml
  5. 22 4
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md
  6. 22 4
      .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md
  7. 322 0
      apps/web/tests/diagram-preview.perf.ts
  8. 6 0
      apps/web/tests/expected/markdown-mermaid/offscreen.expected.md
  9. 49 4
      apps/web/tests/markdown-mermaid.e2e.ts
  10. 1 0
      apps/web/tsconfig.json
  11. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  12. 5 5
      packages/client/ui-primitives/README.md
  13. 5 5
      packages/client/ui-primitives/README.zh.md
  14. 6 1
      packages/client/ui-primitives/src/markdown/SourcePreview.module.css
  15. 79 47
      packages/client/ui-primitives/src/markdown/SourcePreview.tsx
  16. 1 0
      packages/client/ui-primitives/src/markdown/graphviz.ts
  17. 2 0
      packages/client/ui-primitives/src/markdown/mermaid.ts
  18. 3 44
      packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts
  19. 29 0
      packages/client/ui-primitives/src/markdown/viewport.ts
  20. 9 0
      packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts
  21. 112 1
      packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx
  22. 18 1
      packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts
  23. 72 0
      packages/client/ui-primitives/tests/source-preview.client.spec.tsx
  24. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
-2026-09-09-markdown-static-previews.md: cd434e7df5b7b8b51724a06c805618269acd6bde
-2026-09-09-markdown-static-previews.zh.md: c24a8d23baca61e7e9252be337298bd7808f0d65
+2026-09-09-markdown-static-previews.md: cb63b0ccd02abfaf148e61ef452cb714765bfffe
+2026-09-09-markdown-static-previews.zh.md: 7dec68564136d2e9d677c1fd03d8a0b6840cf81b

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md

@@ -16,7 +16,7 @@ The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, and `s
 
 `SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
 
-Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact.
+Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Intersecting previews and open lightboxes observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact.
 
 SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md).
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 `SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
 
-Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
+Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。与视口相交的预览和已打开的 lightbox 观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
 
 SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md
-2026-09-10-codeblock-preview-interaction.md: 8e961b5ed2b8b991fb6a7d177467737c597a0e1b
-2026-09-10-codeblock-preview-interaction.zh.md: 99e12f741e9777287b0ada1d30cbcba551202b24
+2026-09-10-codeblock-preview-interaction.md: 678daa6544f92ab5216251658f9e9471eae4cf10
+2026-09-10-codeblock-preview-interaction.zh.md: afd3a3e2416765b3f71832ddd867c83bf90d8e81

+ 22 - 4
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md

@@ -12,11 +12,13 @@ Readers need to inspect diagrams directly and return to source without paying fo
 
 Supported settled blocks initially show Preview. Supported streaming blocks show a 180px image placeholder with a localized loading status and a decorative glyph whose opacity pulses over 1.8 seconds; reduced motion disables the animation. Streaming mounts neither source highlighting nor diagram generation, including after a fence freezes in the incremental parser. Controls return when message streaming ends; the placeholder remains until the image loads. Stopping the stream also exits the waiting state; invalid or unloadable images show an error with Source available. This supersedes the source-first and release-on-toggle choices in the [static preview decision](2026-09-09-markdown-static-previews.md), whose renderer isolation, cancellation, security, and distribution rules remain active.
 
-The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation while output is pending or failed. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one.
+The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation when no usable image is available. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one.
 
 The stable `data-code-block-content` wrapper and `contentRef` let sidebar owners retain their scrollport and restore scroll position. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) supersedes this note's active-body height and inline-resize choices; it owns current Source/Preview geometry and overflow.
 
-Each mounted `CodeBlock` owns one preview result and pending renderer call. Switching views retains both; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions.
+Each mounted `CodeBlock` owns its preview result and pending renderer call. A shared IntersectionObserver defers diagram work and runtime loading until intersection, and cancels unfinished work on exit unless the lightbox is open. Browsers without this API render immediately. Switching views retains completed results; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. Reentry reuses results with matching source, renderer and palette. Theme refreshes retain the loaded image, its geometry and the open lightbox until a replacement loads. A failed refresh preserves the usable image and displays the localized error. Retention is bounded to one displayed image and one replacement per block.
+
+The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions.
 
 The lightbox fits the image within 88% of viewport width and 84% of viewport height, independently of inline size. Pointer capture owns dragging; wheel zoom preserves the image point beneath the cursor and stays between 0.25 and 8 times the fitted size. Double-click, Home, source-image changes, and viewport resizing restore the fit. Arrow keys pan and +/− zoom. Gestures write only the image transform and retain its URL; they do not schedule React state or diagram generation. Closing releases capture and listeners and restores focus to the opener.
 
@@ -34,6 +36,22 @@ The source renderer consumes each highlight frame once. `StreamingHighlightSessi
 
 Previewable blocks mount and highlight source on first selection; preview-only readers do not mount source. Ordinary source-only blocks still defer highlighting until they intersect; streaming diagram placeholders do not mount source.
 
-Default previews submit work for every mounted settled supported block, including off-screen blocks. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout.
+Offscreen previews submit no diagram work until intersection; offscreen theme changes wait for reentry. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout.
+
+The [viewport tests](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx) pin deferred rendering, viewport cancellation and reuse; the 20-block regression calls every renderer against the unoptimized implementation. The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions.
+
+
+## Performance evidence
+
+The [manual browser diagnostic](../../../../apps/web/tests/diagram-preview.perf.ts), run with `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts`, uses 20 Mermaid flowcharts with 10 nodes and 12 edges each, followed by 40 paragraphs. Three fresh Chromium instances use a 1680×1000 viewport, the shipped Web composition and built Client on macOS arm64, Apple M5 Pro and Node 24.20.0. Opening starts at a real Session click and ends when the tail is visible plus two animation-frame opportunities; theme timing starts immediately before system-theme emulation and ends after a visible replacement plus two frames. These endpoints do not measure hardware presentation or model latency.
+
+| Metric | Eager previews, samples → median | Viewport previews, samples → median |
+| --- | --- | --- |
+| Open endpoint (ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
+| Long tasks in first 3 seconds (ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
+| Additional initial JS transfer (bytes) | 239449 in every sample | 0 in every sample |
+| Initially decoded offscreen diagrams | 20 in every sample | 0 in every sample |
+| Visible theme replacement (ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
+| Theme placeholder frames / height range | 2 / 180–632px in every sample | 0 / 632–632px in every sample |
 
-The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions.
+Theme preparation scrolls to the loaded target after cold diagrams settle; otherwise an earlier diagram's growth can move the target outside the viewport. Both compared theme phases contain two intersecting previews. First activation and its geometry changes are excluded from timing. Unrelated host activity and filesystem caches are uncontrolled; timings are descriptive, with no CI timing threshold or memory claim. The owning component regression fails on eager rendering, and the browser case verifies deferred Mermaid and Graphviz requests plus a keyless offscreen-state snapshot.

+ 22 - 4
.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md

@@ -12,11 +12,13 @@ Status: implemented
 
 支持预览的定稿代码块初始显示预览。受支持的流式代码块显示高 180px 的图片占位、本地化加载状态和以 1.8 秒周期改变透明度的装饰图标;减少动态效果设置禁用动画。流式期间既不挂载源码高亮,也不生成图表,包括 fence 被增量解析器冻结之后。消息流式结束后恢复控件;占位保留至图片加载完成。停止输出也会退出等待状态;无效或无法加载的图片显示错误,并保留源码入口。这取代[静态预览决策](2026-09-09-markdown-static-previews.zh.md)中的默认源码和切换时释放结果的选择;其中的渲染器隔离、取消、安全与分发规则继续适用。
 
-语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;输出待完成或失败时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。
+语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。
 
 稳定的 `data-code-block-content` 容器与 `contentRef` 让侧栏所有者保留滚动容器并恢复滚动位置。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)取代本文中由当前正文定高与行内调整尺寸的选择;它负责当前的源码/预览几何与溢出行为。
 
-每个已挂载的 `CodeBlock` 拥有一个预览结果及待完成的 renderer 调用。切换视图保留二者;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。
+每个已挂载的 `CodeBlock` 拥有预览结果及待完成的 renderer 调用。共享 IntersectionObserver 把图表生成与运行时加载推迟到与视口相交之后;离开视口会取消未完成的工作,已打开 lightbox 时除外。不支持该 API 的浏览器立即渲染。切换视图保留已完成结果;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。重新进入时复用源码、renderer 与配色相同的结果。主题刷新保留已加载图片、其几何尺寸和已打开的 lightbox,直到替换图片加载完成。刷新失败时保留可用图片并显示本地化错误。每个代码块最多保留一张已显示图片和一张替换图片。
+
+所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。
 
 大图以屏幕宽度的 88% 和高度的 84% 为上限等比适应,不依赖行内尺寸。指针捕获管理拖拽;滚轮缩放保持鼠标下的图片位置不动,倍率限制在适应尺寸的 0.25 至 8 倍。双击、Home、图片源变化和视口尺寸变化会恢复适应尺寸。方向键平移,+/− 缩放。手势只写入图片 transform 并保留 URL,不调度 React state 或重新生成图表。关闭时释放指针捕获和监听器,并将焦点还给打开按钮。
 
@@ -34,6 +36,22 @@ Status: implemented
 
 支持预览的代码块在首次选中源码时挂载并高亮;只阅读预览时不挂载源码。普通纯源码代码块仍推迟到进入视口后高亮;流式图表占位不挂载源码。
 
-默认预览会为每个已挂载且支持预览的定稿代码块提交工作,包括视口外的代码块。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。
+视口外的预览直到与视口相交后才提交图表工作;视口外的主题变化等待重新进入视口后处理。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。
+
+[视口测试](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx)固定延迟渲染、视口取消和复用行为;20 个代码块的回归用例在未优化实现上会调用全部 renderer。[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。
+
+
+## 性能证据
+
+[手动浏览器诊断](../../../../apps/web/tests/diagram-preview.perf.ts)通过 `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts` 运行,使用 20 个 Mermaid 流程图,每图 10 个节点和 12 条边,后接 40 段文本。三个全新 Chromium 实例使用 1680×1000 视口、正式 Web 组合和已构建 Client,运行于 macOS arm64、Apple M5 Pro 与 Node 24.20.0。打开阶段从真实 Session 点击开始,到尾部可见再加两次动画帧机会结束;主题阶段从模拟系统主题前开始,到替换图片可见再加两帧结束。这些终点不测量硬件呈现或模型延迟。
+
+| 指标 | 挂载即渲染,样本 → 中位数 | 视口渲染,样本 → 中位数 |
+| --- | --- | --- |
+| 打开终点(ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
+| 前 3 秒长任务(ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
+| 初始新增 JS 传输(bytes) | 每个样本均为 239449 | 每个样本均为 0 |
+| 初始已解码的视口外图表 | 每个样本均为 20 | 每个样本均为 0 |
+| 可见主题替换(ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
+| 主题占位帧/高度范围 | 每个样本均为 2 / 180–632px | 每个样本均为 0 / 632–632px |
 
-[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。
+主题阶段的准备会在冷图表布局稳定后滚到已加载的目标;否则前方图表增高可能把目标移出视口。对比的两个主题阶段都包含两个与视口相交的预览。首次激活及其几何变化不计时。其他主机活动与文件系统缓存不受控制;计时仅作描述,不设 CI 时间阈值,也不声明内存收益。对应组件回归在挂载即渲染实现上失败,浏览器用例验证 Mermaid 与 Graphviz 请求的延迟加载及无需 API key 的屏外状态快照。

+ 322 - 0
apps/web/tests/diagram-preview.perf.ts

@@ -0,0 +1,322 @@
+/** Manual built-Web diagnostic for offscreen diagrams and visible theme refreshes; no timing budgets. */
+import { execFileSync } from 'node:child_process'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { arch, cpus, platform, release } from 'node:os'
+import { join } from 'node:path'
+import { chromium } from 'playwright'
+import type { Browser, Locator, Page } from 'playwright'
+import { describe, expect, it } from 'vitest'
+import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-title'
+import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
+import type { WebScaffold } from './scaffold.ts'
+import { newEnglishPage, REPO_ROOT } from './support.ts'
+
+const SAMPLE_COUNT = 3
+const DIAGRAM_COUNT = 20
+const TAIL_PARAGRAPHS = 40
+const OBSERVATION_MS = 3_000
+const SESSION_ID = 'diagram-preview-performance'
+const TITLE = 'DIAGRAM_PREVIEW_PERF'
+const TAIL = 'DIAGRAM_PREVIEW_TAIL'
+const PREVIEW = '[data-code-block-preview]'
+const PROBE_KEY = '__dshDiagramPreviewPerf'
+
+interface LongTask {
+  readonly startTime: number
+  readonly duration: number
+}
+
+interface Frame {
+  readonly elapsedMs: number
+  readonly height: number
+  readonly placeholder: boolean
+}
+
+interface Probe {
+  start?: number
+  opportunity?: number
+  oldSrc?: string
+  trustedClick?: boolean
+  frames: Frame[]
+  longTasks: LongTask[]
+  observer: PerformanceObserver
+  raf: number
+}
+
+function fixture(): string {
+  const session = Session.create(SessionId(SESSION_ID))
+  session.append('turn/start', { turn: 1 })
+  const user = session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text: 'Inspect this synthetic diagram history.' }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } })
+  session.append('step/start', { turn: 1, step: 1 })
+  const diagrams = Array.from({ length: DIAGRAM_COUNT }, (_, index) => [
+    `Diagram ${index + 1}`,
+    '```mermaid',
+    'flowchart TD',
+    `  A[Request ${index + 1}] --> B[Read history]`,
+    '  B --> C{Cached}',
+    '  C --> D[Load source]',
+    '  C --> E[Reuse source]',
+    '  D --> F[Parse]',
+    '  E --> F',
+    '  F --> G[Layout]',
+    '  G --> H[Draw]',
+    '  H --> I{Loaded}',
+    '  I --> J[Display]',
+    '  I --> D',
+    '  E --> G',
+    '```',
+  ].join('\n'))
+  const paragraphs = Array.from({ length: TAIL_PARAGRAPHS }, (_, index) =>
+    `History note ${index + 1}. This ordinary paragraph keeps the diagrams above the initial reading position. `
+    + 'The reader can inspect the completed discussion without opening a chart.')
+  session.append('assistant/message', {
+    turn: 1,
+    step: 1,
+    stream: [],
+    message: createAssistantMessage({
+      content: [{ type: 'text', text: [...diagrams, ...paragraphs, TAIL].join('\n\n') }],
+      source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }),
+    usage: { inputTokens: 20, outputTokens: 2_000 },
+  }, { surfaceOp: 'append' })
+  session.append('step/end', { turn: 1, step: 1 })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  return [JSON.stringify({
+    type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
+    createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
+  }), ...session.snapshotEvents().map(event => JSON.stringify(event)), ''].join('\n')
+}
+
+async function installProbe(phase: 'open' | 'theme', target: Locator): Promise<void> {
+  await target.evaluate((node, options) => {
+    const longTasks: LongTask[] = []
+    const observer = new PerformanceObserver((list) => {
+      for (const entry of list.getEntries()) longTasks.push({ startTime: entry.startTime, duration: entry.duration })
+    })
+    observer.observe({ type: 'longtask' })
+    const probe: Probe = { frames: [], longTasks, observer, raf: 0 }
+    Reflect.set(globalThis, options.key, probe)
+    const visible = (element: Element): boolean => {
+      const rect = element.getBoundingClientRect()
+      const scroll = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
+      const style = getComputedStyle(element)
+      return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden'
+        && rect.bottom > (scroll?.top ?? 0) && rect.top < (scroll?.bottom ?? innerHeight)
+    }
+    const frame = (): void => {
+      if (probe.start === undefined) return
+      let ready = false
+      if (options.phase === 'open') {
+        const tail = Array.from(document.querySelectorAll('[data-conversation-scroll] p'))
+          .find(element => element.textContent === options.tail)
+        ready = tail !== undefined && visible(tail)
+      } else {
+        const preview = document.querySelectorAll(options.preview)[options.diagramCount - 1]
+        if (preview === undefined) throw new Error('theme probe has no last diagram')
+        probe.frames.push({
+          elapsedMs: performance.now() - probe.start,
+          height: preview.getBoundingClientRect().height,
+          placeholder: Array.from(preview.querySelectorAll('[data-preview-placeholder]')).some(visible),
+        })
+        ready = Array.from(preview.querySelectorAll('img')).some(image =>
+          image.src !== probe.oldSrc && image.complete && image.naturalWidth > 0 && visible(image))
+      }
+      if (ready && probe.opportunity === undefined) {
+        // The second callback is a rendering opportunity, not hardware presentation.
+        probe.opportunity = -1
+        requestAnimationFrame(() => {
+          requestAnimationFrame(() => { probe.opportunity = performance.now() })
+        })
+      }
+      probe.raf = requestAnimationFrame(frame)
+    }
+    if (options.phase === 'open') {
+      node.addEventListener('click', (event) => {
+        probe.trustedClick = event.isTrusted
+        probe.start = performance.now()
+        probe.raf = requestAnimationFrame(frame)
+      }, { capture: true, once: true })
+    } else {
+      const image = node.querySelector('img')
+      if (image === null) throw new Error('theme probe has no loaded image')
+      probe.oldSrc = image.src
+      probe.start = performance.now()
+      probe.raf = requestAnimationFrame(frame)
+    }
+  }, { phase, key: PROBE_KEY, preview: PREVIEW, diagramCount: DIAGRAM_COUNT, tail: TAIL })
+}
+
+async function collectProbe(page: Page) {
+  try {
+    await page.waitForFunction(({ key, windowMs }) => {
+      const probe = Reflect.get(globalThis, key) as Probe
+      return probe.start !== undefined && (probe.opportunity ?? -1) > 0
+        && performance.now() - probe.start >= windowMs
+    }, { key: PROBE_KEY, windowMs: OBSERVATION_MS }, { timeout: 30_000 })
+  } catch (error) {
+    const diagnostic = await page.evaluate(({ key, previewSelector }) => {
+      const probe = Reflect.get(globalThis, key) as Probe
+      const preview = Array.from(document.querySelectorAll(previewSelector)).at(-1)
+      const bounds = (element: Element | null | undefined) => {
+        const rect = element?.getBoundingClientRect()
+        return rect === undefined ? null : { top: rect.top, bottom: rect.bottom, height: rect.height }
+      }
+      return {
+        opportunity: probe.opportunity,
+        frames: probe.frames.slice(-5),
+        theme: getComputedStyle(document.documentElement).colorScheme,
+        scroll: bounds(document.querySelector('[data-conversation-scroll]')),
+        preview: bounds(preview),
+        images: Array.from(preview?.querySelectorAll('img') ?? []).map(image => ({
+          sameSource: image.src === probe.oldSrc, complete: image.complete, naturalWidth: image.naturalWidth,
+          hidden: image.hidden, rect: bounds(image),
+        })),
+      }
+    }, { key: PROBE_KEY, previewSelector: PREVIEW })
+    throw new Error(`diagram probe endpoint unavailable: ${JSON.stringify(diagnostic)}`, { cause: error })
+  }
+  return await page.evaluate(({ key, windowMs, previewSelector }) => {
+    const probe = Reflect.get(globalThis, key) as Probe
+    probe.observer.disconnect()
+    cancelAnimationFrame(probe.raf)
+    Reflect.deleteProperty(globalThis, key)
+    if (probe.start === undefined || probe.opportunity === undefined) throw new Error('incomplete diagram probe')
+    const start = probe.start
+    const stop = start + windowMs
+    const tasks = probe.longTasks.filter(task => task.startTime < stop && task.startTime + task.duration > start)
+    const scripts = performance.getEntriesByType('resource').filter((entry): entry is PerformanceResourceTiming =>
+      entry instanceof PerformanceResourceTiming && new URL(entry.name).pathname.endsWith('.js')
+      && entry.startTime >= start && entry.startTime < stop)
+      .map(entry => ({
+        path: new URL(entry.name).pathname,
+        transferBytes: entry.transferSize,
+        encodedBytes: entry.encodedBodySize,
+        decodedBytes: entry.decodedBodySize,
+        durationMs: entry.duration,
+      }))
+    const previews = Array.from(document.querySelectorAll(previewSelector))
+    const scrollRect = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
+    const intersectingPreviews = previews.filter((preview) => {
+      const rect = preview.getBoundingClientRect()
+      return rect.bottom > (scrollRect?.top ?? 0) && rect.top < (scrollRect?.bottom ?? innerHeight)
+    }).length
+    const frames = probe.frames.filter(frame => frame.elapsedMs < windowMs)
+    return {
+      opportunityMs: probe.opportunity - start,
+      observedMs: performance.now() - start,
+      trustedClick: probe.trustedClick,
+      longTaskMs: tasks.reduce((sum, task) => sum + Math.max(0,
+        Math.min(stop, task.startTime + task.duration) - Math.max(start, task.startTime)), 0),
+      longTasks: tasks.map(task => ({ startMs: task.startTime - start, durationMs: task.duration })),
+      loadedDiagramImages: previews.flatMap(preview => Array.from(preview.querySelectorAll('img')))
+        .filter(image => image.complete && image.naturalWidth > 0).length,
+      previewCount: previews.length,
+      intersectingPreviews,
+      scriptTransferBytes: scripts.reduce((sum, script) => sum + script.transferBytes, 0),
+      scriptEncodedBytes: scripts.reduce((sum, script) => sum + script.encodedBytes, 0),
+      scripts,
+      frames,
+      heightMin: frames.length === 0 ? null : Math.min(...frames.map(frame => frame.height)),
+      heightMax: frames.length === 0 ? null : Math.max(...frames.map(frame => frame.height)),
+      placeholderFrames: frames.filter(frame => frame.placeholder).length,
+    }
+  }, { key: PROBE_KEY, windowMs: OBSERVATION_MS, previewSelector: PREVIEW })
+}
+
+async function sample() {
+  let scaffold: WebScaffold | undefined
+  let browser: Browser | undefined
+  try {
+    scaffold = await launchWebScaffold()
+    await seedSession(scaffold, fixture(), SESSION_ID)
+    browser = await chromium.launch()
+    const page = await newEnglishPage(browser)
+    await page.emulateMedia({ colorScheme: 'light' })
+    const tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    const group = page.getByRole('treeitem').first()
+    await group.waitFor({ timeout: 30_000 })
+    await group.click()
+    const session = page.getByRole('treeitem').nth(1)
+    await session.waitFor({ timeout: 30_000 })
+    const bootScriptBytes = await page.evaluate(() => performance.getEntriesByType('resource')
+      .filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming
+        && new URL(entry.name).pathname.endsWith('.js'))
+      .reduce((sum, entry) => sum + entry.transferSize, 0))
+    await installProbe('open', session)
+    await session.click()
+    const open = await collectProbe(page)
+    expect(open.trustedClick).toBe(true)
+    expect(open.previewCount).toBe(DIAGRAM_COUNT)
+    expect(open.intersectingPreviews).toBe(0)
+    const last = page.locator(PREVIEW).last()
+    await last.scrollIntoViewIfNeeded()
+    await expect.poll(() => last.locator('img').evaluateAll(images => images.some((node) => {
+      const image = node as HTMLImageElement
+      return image.complete && image.naturalWidth > 0 && image.getBoundingClientRect().height > 0
+    }))).toBe(true)
+    // Earlier cold previews may grow after the placeholder was scrolled into view.
+    await last.scrollIntoViewIfNeeded()
+    await expect.poll(() => last.evaluate((node) => {
+      const rect = node.getBoundingClientRect()
+      const scroll = document.querySelector('[data-conversation-scroll]')!.getBoundingClientRect()
+      return rect.bottom > scroll.top && rect.top < scroll.bottom
+    }), { timeout: 30_000 }).toBe(true)
+    await installProbe('theme', last)
+    await page.emulateMedia({ colorScheme: 'dark' })
+    const theme = await collectProbe(page)
+    expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe('dark')
+    expect(tripwire.pageErrors).toEqual([])
+    return { browser: browser.version(), bootScriptBytes, open, theme, consoleWarnings: tripwire.warnings }
+  } finally {
+    try {
+      await browser?.close()
+    } finally {
+      await scaffold?.close()
+    }
+  }
+}
+
+function median(values: number[]): number {
+  return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!
+}
+
+describe('manual web performance: diagram previews', () => {
+  it('reports cold offscreen opening and a visible theme refresh', async () => {
+    if (webSnapshotMode() !== 'replay') throw new Error('diagram performance requires keyless replay mode')
+    const directory = join(REPO_ROOT, '.playwright-mcp/preview-perf', new Date().toISOString().replaceAll(':', '-'))
+    await mkdir(directory, { recursive: true })
+    const samples: Awaited<ReturnType<typeof sample>>[] = []
+    for (let index = 0; index < SAMPLE_COUNT; index++) {
+      const result = await sample()
+      samples.push(result)
+      await writeFile(join(directory, `sample-${index + 1}.json`), `${JSON.stringify(result, null, 2)}\n`)
+      console.log(JSON.stringify({ sample: index + 1, openMs: result.open.opportunityMs, themeMs: result.theme.opportunityMs }))
+    }
+    const summary = {
+      revision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(),
+      runtime: { node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model },
+      workload: {
+        diagrams: DIAGRAM_COUNT, nodesPerDiagram: 10, edgesPerDiagram: 12,
+        tailParagraphs: TAIL_PARAGRAPHS, viewport: [1680, 1000], observationMs: OBSERVATION_MS,
+      },
+      semantics: 'Fresh Chromium and private shipped-composition scaffold per sample; built Client with source-resolved test Host. Opening starts at trusted session click; theme starts immediately before media emulation. Endpoints require visible output and two animation-frame opportunities. Long tasks are clipped to the fixed observation window. Samples include local transport, no model call. No memory or hardware-presentation measurement and no timing verdict.',
+      medians: Object.fromEntries((['open', 'theme'] as const).map(phase => [phase, {
+        opportunityMs: median(samples.map(sample => sample[phase].opportunityMs)),
+        longTaskMs: median(samples.map(sample => sample[phase].longTaskMs)),
+        scriptTransferBytes: median(samples.map(sample => sample[phase].scriptTransferBytes)),
+        loadedDiagramImages: median(samples.map(sample => sample[phase].loadedDiagramImages)),
+        placeholderFrames: median(samples.map(sample => sample[phase].placeholderFrames)),
+      }])),
+      samples,
+    }
+    await writeFile(join(directory, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
+    console.log(JSON.stringify({ directory, medians: summary.medians }))
+  })
+})

+ 6 - 0
apps/web/tests/expected/markdown-mermaid/offscreen.expected.md

@@ -0,0 +1,6 @@
+- text: mermaid
+- button "Enlarge preview" [disabled]
+- button "Copy"
+- button "Source"
+- button "Preview" [pressed]
+- status: Generating diagram…

+ 49 - 4
apps/web/tests/markdown-mermaid.e2e.ts

@@ -58,7 +58,7 @@ const CPU = `flowchart LR
     MEM -->|"数据"| OUT
     CU -->|"控制信号"| OUT`
 
-function fixture(diagramSources?: string[]): string {
+function fixture(diagramSources?: string[], trailingText = ''): string {
   const session = Session.create(SessionId('markdown-mermaid-source'))
   session.append('turn/start', { turn: 1 })
   const user = session.append('user/message', createUserMessage({
@@ -74,7 +74,7 @@ function fixture(diagramSources?: string[]): string {
         '# Mermaid previews',
         ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``),
         ...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``),
-      ].join('\n\n') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``)].join('\n\n') }],
+      ].join('\n\n') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``), trailingText].join('\n\n') }],
       source: { kind: 'model', provider: 'fixture', model: 'fixture' },
     }),
   }, { surfaceOp: 'append' })
@@ -120,7 +120,9 @@ async function showAllPreviews(page: Page, label = 'Preview'): Promise<void> {
   const blocks = page.locator('.md-code-block')
   for (let index = 0; index < await blocks.count(); index += 1) {
     const button = blocks.nth(index).getByRole('button', { name: label, exact: true })
-    if (await button.count() === 1) await button.click()
+    if (await button.count() !== 1) continue
+    await button.click()
+    await expect.poll(() => blocks.nth(index).locator('[data-preview-placeholder]').count()).toBe(0)
   }
 }
 
@@ -258,6 +260,7 @@ describe('web e2e: Mermaid chat previews', () => {
     try {
       await openConversation(page, scaffold)
       const block = page.locator('.md-code-block').first()
+      await block.scrollIntoViewIfNeeded()
       await block.getByRole('img', { name: 'Mermaid diagram', exact: true }).evaluate(async (node: HTMLImageElement) => { await node.decode() })
       const toolbar = block.locator('[data-code-block-banner]').locator('..')
       await block.hover()
@@ -406,6 +409,7 @@ describe('web e2e: Mermaid chat previews', () => {
     await openConversation(page, scaffold)
     const first = page.locator('.md-code-block').first()
     const invalid = page.locator('.md-code-block').nth(2)
+    await invalid.scrollIntoViewIfNeeded()
     await invalid.getByRole('status').filter({ hasText: 'Unable to render this diagram' }).waitFor()
     await invalid.getByRole('button', { name: 'Source', exact: true }).click()
     expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeCloseTo(120, 0)
@@ -417,6 +421,7 @@ describe('web e2e: Mermaid chat previews', () => {
     const html = page.locator('.md-code-block').nth(6)
     expect(await html.locator('pre code').textContent()).toBe(HTML)
     expect(await html.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(0)
+    await first.scrollIntoViewIfNeeded()
     const image = first.getByRole('img', { name: 'Mermaid diagram', exact: true })
     await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
     const diagram = await image.elementHandle()
@@ -521,10 +526,48 @@ describe('web e2e: Mermaid chat previews', () => {
     await page.getByRole('status').filter({ hasText: '无法渲染此图表,可切换到源码查看。' }).waitFor()
     const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
     await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE)
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md', 'source-highlight.expected.md', 'lightbox.expected.md', 'zh-lightbox.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md', 'source-highlight.expected.md', 'lightbox.expected.md', 'zh-lightbox.expected.md', 'offscreen.expected.md'])
     await page.close()
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('defers offscreen Mermaid and Graphviz runtimes until a preview enters the viewport', async () => {
+    const page = await newEnglishPage(browser)
+    let history: WebScaffold | undefined
+    const runtimes: string[] = []
+    page.on('request', (request) => {
+      const path = new URL(request.url()).pathname
+      if (/\/(mermaid\.core|viz)-[^/]+\.js$/.test(path)) runtimes.push(path)
+    })
+    try {
+      history = await launchWebScaffold({})
+      const tail = ['```dot', DOT, '```', ...Array.from({ length: 40 }, (_, index) => `Closing note ${index + 1}.`), 'History complete.'].join('\n\n')
+      await seedSession(history, fixture(Array.from({ length: 20 }, (_, index) => index === 0 ? FLOW : SEQUENCE), tail), SEED_ID)
+      await openConversation(page, history)
+      await page.getByText('History complete.', { exact: true }).waitFor()
+      await page.evaluate(async () => {
+        await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))
+      })
+      const blocks = page.locator('.md-code-block')
+      expect(await blocks.count()).toBe(21)
+      expect(await blocks.locator('img').count()).toBe(0)
+      expect(runtimes).toEqual([])
+      await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'offscreen.expected.md'),
+        await captureStableAria(page, '.md-code-block', history.workspaceCwd), MODE)
+      await blocks.first().scrollIntoViewIfNeeded()
+      await blocks.first().getByRole('img', { name: 'Mermaid diagram', exact: true })
+        .evaluate(async (node: HTMLImageElement) => { await node.decode() })
+      expect(runtimes.some(path => path.includes('/mermaid.core-'))).toBe(true)
+      expect(runtimes.some(path => path.includes('/viz-'))).toBe(false)
+      await blocks.last().scrollIntoViewIfNeeded()
+      await blocks.last().getByRole('img', { name: 'Graphviz diagram', exact: true })
+        .evaluate(async (node: HTMLImageElement) => { await node.decode() })
+      expect(runtimes.some(path => path.includes('/viz-'))).toBe(true)
+    } finally {
+      await page.close()
+      await history?.close()
+    }
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('rejects native Mermaid image nodes before requests and renders the next diagram', async () => {
     const page = await newEnglishPage(browser)
     let imageScaffold: WebScaffold | undefined
@@ -539,8 +582,10 @@ describe('web e2e: Mermaid chat previews', () => {
         'flowchart LR\n  A@{ img: "https://preview.invalid/mermaid-image", h: 80 }', FLOW,
       ]), SEED_ID)
       await openConversation(page, imageScaffold)
+      await page.locator('.md-code-block').first().scrollIntoViewIfNeeded()
       await page.locator('.md-code-block').first().getByRole('status')
         .filter({ hasText: 'Unable to render this diagram' }).waitFor()
+      await page.locator('.md-code-block').nth(1).scrollIntoViewIfNeeded()
       const image = page.getByRole('img', { name: 'Mermaid diagram', exact: true })
       await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
       expect(requests).toEqual([])

+ 1 - 0
apps/web/tsconfig.json

@@ -134,6 +134,7 @@
     "tests/chat-continuous-conversation.e2e.ts",
     "tests/composer-tab-geometry.e2e.ts",
     "tests/complex-history.perf.ts",
+    "tests/diagram-preview.perf.ts",
     "tests/pwsh-terminal.e2e.ts",
     "tests/workflow-run.e2e.ts"
   ],

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 8c151fb09ea408f4bd96e4ba00c2967c51c0f880
-README.zh.md: a79d90ff232a3035dbb1f99b5a7a5d0490103816
+README.md: e9a6b4d1271d3633b5cdb592ee322c3a60d0aa77
+README.zh.md: 9468ac4905019948bf0f8df5a08a97c80593d4d0

+ 5 - 5
packages/client/ui-primitives/README.md

@@ -76,9 +76,9 @@ The catalog above lists what each export is for; this section covers the behavio
 
 `CodeBlock` highlights `dot`/`graphviz`, `svg`, and `mermaid` when displaying source, with case-insensitive language hints. These grammars load on demand; mounted source automatically gains theme-token colors when loading completes. SVG shares the XML grammar. Mermaid uses the diagram-body rules from `@shikijs/langs/mermaid`. The pinned DOT grammar and its mechanical conversion are recorded in the [third-party notices](THIRD_PARTY_PREVIEW_NOTICES.txt).
 
-Supply `MarkdownLabels.preview` to show settled `mermaid`, `graphviz`/`dot`, and `svg` fences as previews by default. Supported streaming fences show a 180px image placeholder with a pulsing glyph and localized status; controls become available after streaming ends. Consumers without these labels show source. Mermaid and Graphviz use the active document palette on the code-block background and update mounted previews when resolved colors change. Explicit colors in DOT and SVG remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render or image-load failure shows the supplied error label with Source available in the toolbar; replacing the source discards late results from the previous render.
+Supply `MarkdownLabels.preview` to show settled `mermaid`, `graphviz`/`dot`, and `svg` fences as previews by default. Supported streaming fences show a 180px image placeholder with a pulsing glyph and localized status; controls become available after streaming ends. Consumers without these labels show source. Mermaid and Graphviz use the active document palette on the code-block background and refresh intersecting previews or open lightboxes when resolved colors change. Offscreen previews defer rendering and runtime loading until entry. Explicit colors in DOT and SVG remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render or image-load failure shows the supplied error label with Source available in the toolbar; replacing the source discards late results from the previous render.
 
-`CodeBlock.preview` supplies a standard source-preview descriptor with a renderer and complete localized output and control labels. Callers do not pass React nodes. The language stays on the left; magnifier and copy icons precede the Source/Preview segmented control on the right. Only the selected background slides for 160ms; the body switches immediately. Unsupported blocks show a static selected Source label. Copy always reads source and confirms success through its icon and tooltip without changing toolbar width. Preview shows a magnifier that opens a body-portaled lightbox fitted within 88% of viewport width and 84% of its height. Drag to pan, scroll around the cursor to zoom, or double-click to fit again. Arrow keys pan, +/− zoom, and Home resets. The lightbox reuses the generated image; pending or failed output disables it with a localized accessible explanation. Source replaces the magnifier with a line-number toggle that preserves the source text and copy output. In Preview, the toolbar fades out through opacity alone over 160ms and appears on block hover or keyboard focus; touch devices keep it visible. Source keeps its toolbar visible.
+`CodeBlock.preview` supplies a standard source-preview descriptor with a renderer and complete localized output and control labels. Callers do not pass React nodes. The language stays on the left; magnifier and copy icons precede the Source/Preview segmented control on the right. Only the selected background slides for 160ms; the body switches immediately. Unsupported blocks show a static selected Source label. Copy always reads source and confirms success through its icon and tooltip without changing toolbar width. Preview shows a magnifier that opens a body-portaled lightbox fitted within 88% of viewport width and 84% of its height. Drag to pan, scroll around the cursor to zoom, or double-click to fit again. Arrow keys pan, +/− zoom, and Home resets. The lightbox reuses the generated image; output without a usable image disables it with a localized accessible explanation. Source replaces the magnifier with a line-number toggle that preserves the source text and copy output. In Preview, the toolbar fades out through opacity alone over 160ms and appears on block hover or keyboard focus; touch devices keep it visible. Source keeps its toolbar visible.
 
 Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same canvas as Mermaid; malformed SVG and DOT show an error and leave Source available. The browser sizes previews from the image aspect ratio and available width, limiting image height to the smaller of 60vh and 640px. The canvas has 16px padding and a 120px minimum height. Source mounts on first selection, scrolls within that same area, and remains mounted with the generated image across view changes. HTML fences use the ordinary highlighted-code presentation.
 
@@ -122,7 +122,7 @@ While a reply streams, `MarkdownText` parses incrementally: all but the trailing
 
 ### Preview ownership
 
-The streaming placeholder mounts neither the source highlighter nor the diagram renderer. Once streaming ends, the waiting canvas remains until the image loads; stopping a stream also exits the streaming wait. Reduced motion disables its 1.8-second opacity animation. Each mounted block retains its preview across view changes and regenerates for source, renderer, or resolved-theme changes. Previewable blocks mount and highlight source on first selection, then retain its DOM; source-only blocks retain viewport activation. Independent memoized source and copy controls keep toolbar feedback and unchanged parent props from redoing source work, while grammar readiness is specific to the source language. The [interaction decision](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md) owns retention, and the [preview sizing decision](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md) owns geometry.
+The streaming placeholder mounts neither the source highlighter nor the diagram renderer. Once streaming ends, the waiting canvas remains until the image loads; stopping a stream also exits the streaming wait. Reduced motion disables its 1.8-second opacity animation. A shared viewport observer activates intersecting previews; leaving cancels unfinished rendering unless the lightbox is open. Each mounted block retains its image across view changes and viewport exits. Reentry reuses matching source, renderer and palette results, including failures. Theme refreshes retain the loaded image and lightbox until the replacement loads; refresh failures keep the usable image and announce the error. Previewable blocks mount and highlight source on first selection, then retain its DOM; source-only blocks retain viewport activation. Independent memoized source and copy controls keep toolbar feedback and unchanged parent props from redoing source work, while grammar readiness is specific to the source language. The [interaction decision](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md) owns retention, and the [preview sizing decision](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md) owns geometry.
 
 ### Geometry and overflow
 
@@ -160,9 +160,9 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
-- **Diagram preview limits** — Mermaid image nodes are rejected before layout to prevent source-authored resource loads. Theme changes temporarily show the placeholder and close the lightbox until the replacement image loads.
+- **Diagram preview limits** — Mermaid image nodes are rejected before layout to prevent source-authored resource loads. Browsers without IntersectionObserver render immediately.
 
-- **Diagram rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Submitted layout work cannot be interrupted. Preview virtualization and worker rendering are not provided.
+- **Diagram rendering runs on the browser thread** — Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Cancellation skips queued work but cannot interrupt active layout. Loaded images and visited source DOM remain until unmount; viewport deferral is not virtualization or worker rendering.
 
 - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
 - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.

+ 5 - 5
packages/client/ui-primitives/README.zh.md

@@ -76,9 +76,9 @@ kind: "package-library"
 
 `CodeBlock` 显示源码时对 `dot`/`graphviz`、`svg` 与 `mermaid` 高亮,语言提示不区分大小写。这些语法按需加载;加载完成后,已挂载的源码会自动采用主题 token 颜色。SVG 复用 XML 语法。Mermaid 使用 `@shikijs/langs/mermaid` 的图表正文规则。固定的 DOT 语法及其机械转换记录在[第三方声明](THIRD_PARTY_PREVIEW_NOTICES.txt)中。
 
-传入 `MarkdownLabels.preview`,让定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence 默认显示预览。受支持的流式 fence 显示高 180px 的图片占位、呼吸图标与本地化状态;流式结束后提供控件。未提供这些文案的调用方显示源码。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,并在解析后的颜色变化时更新已挂载的预览。DOT 与 SVG 中明确指定的颜色保留为作者内容。渲染按需加载 Mermaid,采用严格安全模式,并将生成的 SVG 作为不带图表链接处理器的图片显示。渲染或图片加载失败时显示传入的错误文案,工具栏仍可切换源码;替换源码会丢弃上一次渲染迟到的结果。
+传入 `MarkdownLabels.preview`,让定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence 默认显示预览。受支持的流式 fence 显示高 180px 的图片占位、呼吸图标与本地化状态;流式结束后提供控件。未提供这些文案的调用方显示源码。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,并在解析后的颜色变化时更新与视口相交的预览或已打开的大图。视口外的预览推迟到进入视口后才渲染和加载运行时。DOT 与 SVG 中明确指定的颜色保留为作者内容。渲染按需加载 Mermaid,采用严格安全模式,并将生成的 SVG 作为不带图表链接处理器的图片显示。渲染或图片加载失败时显示传入的错误文案,工具栏仍可切换源码;替换源码会丢弃上一次渲染迟到的结果。
 
-`CodeBlock.preview` 提供标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案。调用方不传入 React 节点。语言名位于左侧;右侧依次放置放大镜、复制图标和源码/预览分段控件。只有选中背景滑动 160ms,正文立即切换。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,并通过图标和 tooltip 确认成功,不改变工具栏宽度。预览中的放大镜打开 body portal lightbox,图片等比适应屏幕宽度的 88% 和高度的 84%。拖拽可平移,滚轮围绕鼠标位置缩放,双击恢复适应屏幕。方向键平移,+/− 缩放,Home 复位。大图复用已生成的图片;结果待完成或失败时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,源码文本和复制结果保持不变。预览工具栏仅通过透明度在 160ms 内淡出,鼠标移入代码块或键盘聚焦时显示;触摸设备保持可见。源码工具栏始终可见。
+`CodeBlock.preview` 提供标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案。调用方不传入 React 节点。语言名位于左侧;右侧依次放置放大镜、复制图标和源码/预览分段控件。只有选中背景滑动 160ms,正文立即切换。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,并通过图标和 tooltip 确认成功,不改变工具栏宽度。预览中的放大镜打开 body portal lightbox,图片等比适应屏幕宽度的 88% 和高度的 84%。拖拽可平移,滚轮围绕鼠标位置缩放,双击恢复适应屏幕。方向键平移,+/− 缩放,Home 复位。大图复用已生成的图片;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,源码文本和复制结果保持不变。预览工具栏仅通过透明度在 160ms 内淡出,鼠标移入代码块或键盘聚焦时显示;触摸设备保持可见。源码工具栏始终可见。
 
 Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出都作为不可执行图片显示在与 Mermaid 相同的画布中;无效 SVG 与 DOT 显示错误并保留源码入口。浏览器按图片宽高比与可用宽度计算预览高度,图片高度不超过 60vh 与 640px 中的较小值。画布保留 16px 内边距与 120px 最小高度。源码在首次选中时挂载,在同一区域内滚动;后续切换保留源码 DOM 与已生成图片。HTML fence 使用普通高亮代码视图。
 
@@ -122,7 +122,7 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
 
 ### 预览所有权
 
-流式占位既不挂载源码高亮组件,也不调用图表渲染器。流式结束后,等待画布保留至图片加载完成;停止输出也会退出流式等待。减少动态效果设置禁用其 1.8 秒透明度动画。每个已挂载的代码块在视图切换时保留预览,并在源码、renderer 或解析后的主题变化时重新生成。支持预览的代码块在首次选中源码时挂载并高亮,之后保留其 DOM;纯源码代码块仍在进入视口后激活高亮。独立 memo 化的源码与复制控件让工具栏反馈和属性未变的父组件更新不必重做源码工作,语法就绪状态也只针对该源码语言。[交互决策](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md)负责保留机制,[预览尺寸决策](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责几何行为。
+流式占位既不挂载源码高亮组件,也不调用图表渲染器。流式结束后,等待画布保留至图片加载完成;停止输出也会退出流式等待。减少动态效果设置禁用其 1.8 秒透明度动画。共享视口观察器激活与视口相交的预览;离开视口会取消未完成的渲染,已打开 lightbox 时除外。每个已挂载的代码块在视图切换和离开视口时保留图片。重新进入时复用源码、renderer 与配色相同的结果,包括失败结果。主题刷新保留已加载图片和 lightbox,直到替换图片加载完成;刷新失败则保留可用图片并报告错误。支持预览的代码块在首次选中源码时挂载并高亮,之后保留其 DOM;纯源码代码块仍在进入视口后激活高亮。独立 memo 化的源码与复制控件让工具栏反馈和属性未变的父组件更新不必重做源码工作,语法就绪状态也只针对该源码语言。[交互决策](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md)负责保留机制,[预览尺寸决策](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责几何行为。
 
 ### 几何与溢出
 
@@ -160,9 +160,9 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
 
 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
-- **图表预览限制**:Mermaid 原生图片节点在布局前被拒绝,避免加载源码指定的资源。主题变化时会暂时显示占位并关闭 lightbox,直到新图加载完成。
+- **图表预览限制**:Mermaid 原生图片节点在布局前被拒绝,避免加载源码指定的资源。不支持 IntersectionObserver 的浏览器会立即渲染。
 
-- **图表渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。已提交的布局工作无法中断。当前不提供预览虚拟化或 worker 渲染。
+- **图表渲染在浏览器线程上执行**:Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。取消会跳过排队工作,但无法中断已开始的布局。已加载图片和访问过的源码 DOM 保留至卸载;视口延迟渲染不等同于虚拟化或 worker 渲染。
 
 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。

+ 6 - 1
packages/client/ui-primitives/src/markdown/SourcePreview.module.css

@@ -1,3 +1,8 @@
+.root {
+  min-width: 0;
+  border-radius: inherit;
+}
+
 .canvas {
   display: grid;
   place-items: center;
@@ -41,7 +46,7 @@
   50% { opacity: 0.8; }
 }
 
-.canvas[hidden] { display: none; }
+.canvas[hidden], .diagram[hidden] { display: none; }
 
 @media (prefers-reduced-motion: reduce) {
   .placeholderIcon { animation: none; }

+ 79 - 47
packages/client/ui-primitives/src/markdown/SourcePreview.tsx

@@ -1,7 +1,6 @@
 /** Read-only diagram preview with image loading status and per-source async ownership. */
 
-import { memo, useCallback, useEffect, useId, useState } from 'react'
-import type { ReactNode } from 'react'
+import { memo, useCallback, useEffect, useId, useRef, useState } from 'react'
 import { createPortal } from 'react-dom'
 import { Tooltip } from '../Tooltip.tsx'
 import { IconSearchOutline16 } from '../icons/index.tsx'
@@ -10,6 +9,7 @@ import { PreviewPlaceholder } from './PreviewPlaceholder.tsx'
 import css from './SourcePreview.module.css'
 import blockCss from './CodeBlock.module.css'
 import { readPreviewTheme } from './preview-theme.ts'
+import { observeViewport } from './viewport.ts'
 
 /** Localized preview output; the diagram source remains verbatim. */
 export interface PreviewLabels {
@@ -42,26 +42,54 @@ export interface SourcePreviewProps {
   actions?: HTMLElement | undefined
 }
 
-type Result =
-  | { kind: 'ok'; code: string; render: PreviewRenderer; src: string; loaded: boolean }
-  | { kind: 'error'; code: string; render: PreviewRenderer }
+type Result = {
+  code: string
+  render: PreviewRenderer
+  palette: string
+  /** Last loaded image of the same source, retained while its replacement loads or fails. */
+  previous: string | undefined
+} & (
+  | { kind: 'ok'; src: string; loaded: boolean }
+  | { kind: 'error' }
+)
+
+function loadedImage(result: Result | null): string | undefined {
+  if (result === null) return undefined
+  switch (result.kind) {
+    case 'ok': return result.loaded ? result.src : result.previous
+    case 'error': return result.previous
+    /* v8 ignore next -- closed-union backstop; every declared result kind is handled above. */
+    default: return assertNever(result)
+  }
+}
 
-/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */
+/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged. */
 function assertNever(value: never): never {
   throw new Error(`unreachable preview result: ${String(value)}`)
 }
 
 /**
- * Display a complete diagram after its image loads, or a localized failure status.
- * @param props - Source and complete localized labels. Source and document theme changes cancel obsolete renders.
+ * Render visible diagrams and retain their loaded images during theme refreshes.
+ * @param props - Source and complete localized labels. Source, visibility and document theme changes cancel obsolete renders.
  * @returns A pending status, inert image or error, with a persistent zoom action.
  */
 export const SourcePreview = memo(function SourcePreview({ code, labels, render, actions }: SourcePreviewProps) {
   const pendingId = useId()
+  const target = useRef<HTMLDivElement>(null)
+  const [visible, setVisible] = useState(false)
   const [result, setResult] = useState<Result | null>(null)
   const [expanded, setExpanded] = useState<{ code: string; render: PreviewRenderer } | null>(null)
   const close = useCallback(() => { setExpanded(null) }, [])
+  const current = result?.code === code && result.render === render ? result : null
+  const renderedPalette = current?.palette
+  const expandedCurrent = expanded?.code === code && expanded.render === render
+  const active = visible || expandedCurrent
+  useEffect(() => {
+    // oxlint-disable-next-line typescript/no-non-null-assertion -- React attaches this surface before effects run.
+    return observeViewport(target.current!, setVisible)
+  }, [])
   useEffect(() => {
+    if (!active) return
     let controller: AbortController | undefined
     let palette: string | undefined
     const update = () => {
@@ -69,11 +97,24 @@ export const SourcePreview = memo(function SourcePreview({ code, labels, render,
       if (next === palette) return
       palette = next
       controller?.abort()
+      if (next === renderedPalette) return
       const current = new AbortController()
       controller = current
       void (async () => await render(code, current.signal))().then(
-        (src) => { if (!current.signal.aborted) setResult({ kind: 'ok', code, render, src, loaded: false }) },
-        () => { if (!current.signal.aborted) setResult({ kind: 'error', code, render }) },
+        (src) => {
+          if (current.signal.aborted) return
+          controller = undefined
+          setResult((prior) => {
+            const previous = prior?.code === code && prior.render === render ? loadedImage(prior) : undefined
+            return { kind: 'ok', code, render, palette: next, src, loaded: src === previous, previous: src === previous ? undefined : previous }
+          })
+        },
+        () => {
+          if (current.signal.aborted) return
+          controller = undefined
+          setResult(prior => ({ kind: 'error', code, render, palette: next,
+            previous: prior?.code === code && prior.render === render ? loadedImage(prior) : undefined }))
+        },
       )
     }
     // Isolated images cannot inherit the host's CSS variables.
@@ -82,41 +123,16 @@ export const SourcePreview = memo(function SourcePreview({ code, labels, render,
     observer.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] })
     update()
     return () => { observer.disconnect(); controller?.abort() }
-  }, [code, render])
+  }, [active, code, render, renderedPalette])
 
-  const current = result?.code === code && result.render === render ? result : null
-  const ready = current?.kind === 'ok' && current.loaded
-  const pending = current === null || (current.kind === 'ok' && !current.loaded)
-  let body: ReactNode
-  if (current === null) {
-    body = <PreviewPlaceholder label={labels.pending} />
-  } else {
-    switch (current.kind) {
-      case 'ok':
-        body = <>
-          {!current.loaded && <PreviewPlaceholder label={labels.pending} />}
-          <div className={css.canvas} hidden={!current.loaded}>
-            <img key={current.src} className={css.diagram} src={current.src} alt={labels.diagram}
-              ref={(image) => {
-                if (!current.loaded && image !== null && image.complete && image.naturalWidth > 0) {
-                  setResult({ ...current, loaded: true })
-                }
-              }}
-              onLoad={() => { setResult({ ...current, loaded: true }) }}
-              onError={() => { setResult({ kind: 'error', code, render }) }} />
-          </div>
-        </>
-        break
-      case 'error':
-        body = <div className={css.failure}>
-          <div className={css.status} role="status">{labels.error}</div>
-        </div>
-        break
-      /* v8 ignore next -- closed-union backstop; only reached if a result is forged */
-      default: return assertNever(current)
-    }
+  const displayed = loadedImage(current)
+  const ready = displayed !== undefined
+  const pending = !ready && current?.kind !== 'error'
+  const markLoaded = () => {
+    if (current?.kind !== 'ok' || current.loaded) return
+    setResult(prior => prior === current ? { ...current, loaded: true, previous: undefined } : prior)
   }
-  return <>
+  return <div ref={target} className={css.root}>
     {actions !== undefined && createPortal(
       <Tooltip label={ready ? labels.zoom : pending ? labels.pending : labels.error} side="top" delayMs={500}>
         <span className={blockCss.iconSlot}>
@@ -128,9 +144,25 @@ export const SourcePreview = memo(function SourcePreview({ code, labels, render,
         </span>
       </Tooltip>, actions,
     )}
-    {body}
-    {ready && expanded?.code === code && expanded.render === render && <PreviewLightbox
-      src={current.src} alt={labels.diagram} closeLabel={labels.close} interactionLabel={labels.interaction} onClose={close}
+    {pending && <PreviewPlaceholder label={labels.pending} />}
+    {current?.kind === 'error' && <div className={css.failure}>
+      <div className={css.status} role="status">{labels.error}</div>
+    </div>}
+    <div className={css.canvas} hidden={!ready}>
+      {current?.previous !== undefined &&
+        <img key={current.previous} className={css.diagram} src={current.previous} alt={labels.diagram} />}
+      {current?.kind === 'ok' && <img key={current.src} className={css.diagram} src={current.src} alt={labels.diagram}
+        hidden={!current.loaded}
+        ref={(image) => {
+          if (image !== null && image.complete && image.naturalWidth > 0) markLoaded()
+        }}
+        onLoad={markLoaded} onError={() => {
+          setResult(prior => prior === current ? { kind: 'error', code, render, palette: current.palette,
+            previous: current.previous } : prior)
+        }} />}
+    </div>
+    {ready && expandedCurrent && <PreviewLightbox
+      src={displayed} alt={labels.diagram} closeLabel={labels.close} interactionLabel={labels.interaction} onClose={close}
     />}
-  </>
+  </div>
 })

+ 1 - 0
packages/client/ui-primitives/src/markdown/graphviz.ts

@@ -13,6 +13,7 @@ let runtime: ReturnType<typeof instance> | undefined
  * @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject.
  */
 export async function renderGraphviz(code: string, signal: AbortSignal): Promise<string> {
+  signal.throwIfAborted()
   runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => {
     runtime = undefined
     throw error

+ 2 - 0
packages/client/ui-primitives/src/markdown/mermaid.ts

@@ -69,6 +69,7 @@ export async function renderMermaid(code: string, signal: AbortSignal): Promise<
 }
 
 async function renderOne(code: string, signal: AbortSignal): Promise<string> {
+  signal.throwIfAborted()
   const mermaid = await loadMermaid()
   signal.throwIfAborted()
   initialize(mermaid)
@@ -76,6 +77,7 @@ async function renderOne(code: string, signal: AbortSignal): Promise<string> {
   // The replacement parse API exposes validity, not parsed nodes.
   // oxlint-disable-next-line typescript/no-deprecated
   const diagram = await mermaid.mermaidAPI.getDiagramFromText(code)
+  signal.throwIfAborted()
   if (diagram.type.startsWith('flowchart')) {
     for (const node of (diagram.db as FlowDB).getVertices().values()) {
       if (node.img !== undefined) throw new Error('Mermaid image nodes are not supported')

+ 3 - 44
packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts

@@ -1,48 +1,7 @@
 import { useCallback, useEffect, useState } from 'react'
 import type { RefObject } from 'react'
 import { supportsHighlighting } from './highlight.ts'
-
-const noop = (): void => {}
-
-/** One document-wide observer; activated elements leave it permanently. */
-class HighlightViewport {
-  private observer: IntersectionObserver | undefined
-  private readonly activators = new Map<Element, () => void>()
-
-  observe(element: Element, activate: () => void): () => void {
-    if (typeof IntersectionObserver === 'undefined') {
-      activate()
-      return noop
-    }
-    this.observer ??= new IntersectionObserver((entries) => {
-      for (const entry of entries) {
-        if (!entry.isIntersecting) continue
-        const current = this.activators.get(entry.target)
-        /* v8 ignore next -- the observer reports only elements still registered with it. */
-        if (current === undefined) continue
-        this.activators.delete(entry.target)
-        this.observer?.unobserve(entry.target)
-        current()
-      }
-      this.releaseEmptyObserver()
-    })
-    this.activators.set(element, activate)
-    this.observer.observe(element)
-    return () => {
-      this.activators.delete(element)
-      this.observer?.unobserve(element)
-      this.releaseEmptyObserver()
-    }
-  }
-
-  private releaseEmptyObserver(): void {
-    if (this.activators.size > 0) return
-    this.observer?.disconnect()
-    this.observer = undefined
-  }
-}
-
-const highlightViewport = new HighlightViewport()
+import { observeViewport } from './viewport.ts'
 
 /**
  * Activate one supported code surface when it first intersects the viewport.
@@ -60,14 +19,14 @@ export function useViewportHighlighting(
 ): boolean {
   const supported = supportsHighlighting(lang)
   const [activated, setActivated] = useState(initiallyActive)
-  const activate = useCallback(() => { setActivated(true) }, [])
+  const activate = useCallback((visible: boolean) => { if (visible) setActivated(true) }, [])
 
   useEffect(() => {
     if (activated || !supported) return
     const element = target.current
     /* v8 ignore next -- React attaches the host ref before running effects. */
     if (element === null) return
-    return highlightViewport.observe(element, activate)
+    return observeViewport(element, activate)
   }, [activate, activated, supported, target])
 
   return activated && supported

+ 29 - 0
packages/client/ui-primitives/src/markdown/viewport.ts

@@ -0,0 +1,29 @@
+/** Shared viewport observation for deferred code and diagram work. */
+
+let observer: IntersectionObserver | undefined
+const listeners = new Map<Element, (visible: boolean) => void>()
+
+/**
+ * Observe visibility until disposal; unsupported browsers activate immediately.
+ * @param element - Stable surface whose geometry remains available while work is deferred.
+ * @param changed - Receives intersection changes, including exits.
+ * @returns A disposer that releases the shared observer after the last surface leaves.
+ */
+export function observeViewport(element: Element, changed: (visible: boolean) => void): () => void {
+  if (typeof IntersectionObserver === 'undefined') {
+    changed(true)
+    return () => {}
+  }
+  observer ??= new IntersectionObserver((entries) => {
+    for (const entry of entries) listeners.get(entry.target)?.(entry.isIntersecting)
+  })
+  listeners.set(element, changed)
+  observer.observe(element)
+  return () => {
+    listeners.delete(element)
+    observer?.unobserve(element)
+    if (listeners.size > 0) return
+    observer?.disconnect()
+    observer = undefined
+  }
+}

+ 9 - 0
packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts

@@ -55,3 +55,12 @@ it('uses theme defaults without rewriting authored DOT attributes', async () =>
     else document.body.setAttribute('style', previous)
   }
 })
+
+
+it('does not initialize the runtime for already cancelled work', async () => {
+  const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
+  const controller = new AbortController()
+  controller.abort()
+  await expect(renderGraphviz('digraph {}', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+  expect(instance).not.toHaveBeenCalled()
+})

+ 112 - 1
packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx

@@ -2,6 +2,7 @@
 
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SourcePreview } from '../src/markdown/SourcePreview.tsx'
 import { ReadBlock } from '../src/ReadBlock.tsx'
 import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
 import { markdownLabels, readBlockLabels } from './labels.client.ts'
@@ -149,7 +150,7 @@ describe('viewport-activated syntax highlighting', () => {
 
     view.rerender(<CodeBlock {...props} />)
     expect(block.querySelector('pre.shiki')).toBe(highlighted)
-    expect(IntersectionObserverStub.instances).toHaveLength(1)
+    expect(IntersectionObserverStub.instances.every(instance => instance.disconnected)).toBe(true)
   })
 
   it('releases the shared observer when the last pending block unmounts', () => {
@@ -202,3 +203,113 @@ describe('viewport-activated syntax highlighting', () => {
     })
   })
 })
+
+
+describe('viewport-activated diagram previews', () => {
+  const labels = { diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' }
+  const imageUrl = 'data:image/svg+xml,%3Csvg%2F%3E'
+
+  it('leaves an offscreen history unrendered and activates only visible previews', async () => {
+    const renderer = vi.fn(async () => imageUrl)
+    const view = render(<>{Array.from({ length: 20 }, (_, index) =>
+      <SourcePreview key={index} code={String(index)} render={renderer} labels={labels} />)}</>)
+    expect(renderer).not.toHaveBeenCalled()
+    expect(view.container.querySelectorAll('img')).toHaveLength(0)
+    expect(IntersectionObserverStub.instances).toHaveLength(1)
+    const observer = IntersectionObserverStub.instances[0]!
+    expect(observer.observed.size).toBe(20)
+    const target = [...observer.observed][19]!
+    await act(async () => { observer.intersect(target, true) })
+    expect(renderer).toHaveBeenCalledExactlyOnceWith('19', expect.any(AbortSignal))
+    fireEvent.load(view.container.querySelector('img')!)
+    const image = screen.getByRole('img')
+    await act(async () => { observer.intersect(target, false) })
+    await act(async () => { observer.intersect(target, true) })
+    expect(renderer).toHaveBeenCalledOnce()
+    expect(screen.getByRole('img')).toBe(image)
+    view.unmount()
+    expect(observer.observed.size).toBe(0)
+    expect(observer.disconnected).toBe(true)
+  })
+
+  it('cancels work leaving the viewport and ignores its completion after reentry', async () => {
+    const first = Promise.withResolvers<string>()
+    const renderer = vi.fn((_code: string, _signal: AbortSignal) => first.promise).mockResolvedValueOnce(imageUrl)
+    const view = render(<SourcePreview code="first" render={renderer} labels={labels} />)
+    const observer = IntersectionObserverStub.instances[0]!
+    const target = [...observer.observed][0]!
+    await act(async () => { observer.intersect(target, true) })
+    fireEvent.load(view.container.querySelector('img')!)
+    await act(async () => { observer.intersect(target, false) })
+    view.rerender(<SourcePreview code="second" render={renderer} labels={labels} />)
+    expect(renderer).toHaveBeenCalledOnce()
+    await act(async () => { observer.intersect(target, true) })
+    const signal = renderer.mock.calls[1]![1]
+    await act(async () => { observer.intersect(target, false) })
+    expect(signal.aborted).toBe(true)
+    renderer.mockResolvedValueOnce(imageUrl)
+    await act(async () => { observer.intersect(target, true) })
+    fireEvent.load(view.container.querySelector('img')!)
+    await act(async () => { first.resolve('obsolete') })
+    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+    expect(renderer).toHaveBeenCalledTimes(3)
+  })
+
+  it('keeps an offscreen lightbox active and cancels its unfinished refresh on close', async () => {
+    const replacement = Promise.withResolvers<string>()
+    const renderer = vi.fn((_code: string, _signal: AbortSignal) => replacement.promise).mockResolvedValueOnce(imageUrl)
+    const actions = document.createElement('div')
+    document.body.append(actions)
+    const previous = document.documentElement.style.colorScheme
+    const view = render(<SourcePreview code="diagram" render={renderer} labels={labels} actions={actions} />)
+    try {
+      const observer = IntersectionObserverStub.instances[0]!
+      const target = [...observer.observed][0]!
+      await act(async () => { observer.intersect(target, true) })
+      fireEvent.load(view.container.querySelector('img')!)
+      fireEvent.click(screen.getByRole('button', { name: labels.zoom }))
+      await act(async () => { observer.intersect(target, false) })
+      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
+      expect(renderer).toHaveBeenCalledTimes(2)
+      const signal = renderer.mock.calls[1]![1]
+      expect(signal.aborted).toBe(false)
+      expect(screen.getByRole('dialog')).toBeDefined()
+      fireEvent.click(screen.getByRole('button', { name: labels.close }))
+      expect(signal.aborted).toBe(true)
+      await act(async () => { replacement.reject(new Error('cancelled')) })
+      expect(screen.queryByRole('status')).toBeNull()
+      expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+    } finally {
+      view.unmount()
+      actions.remove()
+      document.documentElement.style.colorScheme = previous
+    }
+  })
+
+  it('defers offscreen theme updates and retains the loaded image until reentry refresh loads', async () => {
+    const renderer = vi.fn(async () => imageUrl)
+    const previous = document.documentElement.style.colorScheme
+    const view = render(<SourcePreview code="diagram" render={renderer} labels={labels} />)
+    try {
+      const observer = IntersectionObserverStub.instances[0]!
+      const target = [...observer.observed][0]!
+      await act(async () => { observer.intersect(target, true) })
+      fireEvent.load(view.container.querySelector('img')!)
+      const image = screen.getByRole('img')
+      await act(async () => { observer.intersect(target, false) })
+      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
+      expect(renderer).toHaveBeenCalledOnce()
+      expect(screen.getByRole('img')).toBe(image)
+      renderer.mockResolvedValueOnce('data:image/svg+xml,new')
+      await act(async () => { observer.intersect(target, true) })
+      expect(renderer).toHaveBeenCalledTimes(2)
+      expect(screen.getByRole('img')).toBe(image)
+      expect(screen.queryByRole('status')).toBeNull()
+      fireEvent.load(view.container.querySelector('img[src="data:image/svg+xml,new"]')!)
+      expect(screen.getByRole('img').getAttribute('src')).toBe('data:image/svg+xml,new')
+    } finally {
+      view.unmount()
+      document.documentElement.style.colorScheme = previous
+    }
+  })
+})

+ 18 - 1
packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts

@@ -116,12 +116,29 @@ describe('Mermaid runtime', () => {
     expect(svg.getAttribute('height')).toBe('180')
   })
 
-  it('does not start cancelled work after loading the runtime', async () => {
+  it('does not import the runtime for cancelled queued work', async () => {
     const { renderMermaid } = await import('../src/markdown/mermaid.ts')
     const controller = new AbortController()
     controller.abort()
     await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
     expect(renderDiagram).not.toHaveBeenCalled()
+    expect(importRuntime).not.toHaveBeenCalled()
+  })
+
+  it('skips layout after cancellation while loading the diagram parser', async () => {
+    const parsed = Promise.withResolvers<{ type: string; db: object }>()
+    getDiagramFromText.mockReturnValueOnce(parsed.promise)
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    const controller = new AbortController()
+    const result = renderMermaid('sequenceDiagram', controller.signal)
+    const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
+    await vi.waitFor(() => { expect(getDiagramFromText).toHaveBeenCalledOnce() })
+    controller.abort()
+    parsed.resolve({ type: 'sequence', db: {} })
+    await rejection
+    expect(renderDiagram).not.toHaveBeenCalled()
+    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
+    await expect(renderMermaid('next', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
   })
 
   it('allows another attempt after runtime initialization fails', async () => {

+ 72 - 0
packages/client/ui-primitives/tests/source-preview.client.spec.tsx

@@ -42,6 +42,78 @@ afterEach(() => {
 beforeEach(() => { vi.resetAllMocks() })
 
 describe('SourcePreview', () => {
+  it('keeps a loaded preview and its lightbox while a theme replacement renders and loads', async () => {
+    const replacement = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockResolvedValueOnce(imageUrl).mockReturnValueOnce(replacement.promise)
+    const actions = document.createElement('div')
+    actions.dataset['testPreviewActions'] = ''
+    document.body.append(actions)
+    const previous = document.documentElement.style.colorScheme
+    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} actions={actions} />)
+    try {
+      const image = await loadPreviewImage()
+      fireEvent.click(screen.getByRole('button', { name: labels.zoom }))
+      const dialog = screen.getByRole('dialog')
+      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
+      expect(view.container.querySelector('img')).toBe(image)
+      expect(screen.getByRole('dialog')).toBe(dialog)
+      await act(async () => { replacement.resolve('data:image/svg+xml,new') })
+      expect(screen.queryByRole('status')).toBeNull()
+      expect(within(dialog).getByRole('img').getAttribute('src')).toBe(imageUrl)
+      expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
+      fireEvent.load(view.container.querySelector('img[src="data:image/svg+xml,new"]')!)
+      expect(screen.getByRole('dialog')).toBe(dialog)
+      expect(within(dialog).getByRole('img').getAttribute('src')).toBe('data:image/svg+xml,new')
+    } finally {
+      view.unmount()
+      document.documentElement.style.colorScheme = previous
+    }
+  })
+
+  it.each(['render', 'load'] as const)('retains the usable image and announces a failed theme %s', async (failure) => {
+    vi.mocked(renderMermaid).mockResolvedValueOnce(imageUrl)
+    const previous = document.documentElement.style.colorScheme
+    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
+    try {
+      const image = await loadPreviewImage()
+      if (failure === 'render') vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('layout failed'))
+      else vi.mocked(renderMermaid).mockResolvedValueOnce('data:image/svg+xml,broken')
+      await act(async () => { document.documentElement.style.colorScheme = 'dark' })
+      if (failure === 'load') fireEvent.error(view.container.querySelector('img[src="data:image/svg+xml,broken"]')!)
+      expect(screen.getByRole('status').textContent).toBe(labels.error)
+      expect(screen.getByRole('img')).toBe(image)
+      expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
+    } finally {
+      view.unmount()
+      document.documentElement.style.colorScheme = previous
+    }
+  })
+
+  it('reports failure from a replacement renderer without restoring the old renderer image', async () => {
+    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
+    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
+    await loadPreviewImage()
+    const replacement = vi.fn(async () => { throw new Error('unavailable') })
+    view.rerender(<SourcePreview code={source} render={replacement} labels={labels} />)
+    expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
+    expect(screen.queryByRole('img')).toBeNull()
+  })
+
+  it.each(['load', 'error'] as const)('ignores a queued image %s event after another event settled the candidate', async (event) => {
+    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
+    const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
+    const image = await waitFor(() => {
+      expect(view.container.querySelector('img')).not.toBeNull()
+      return view.container.querySelector('img')!
+    })
+    act(() => {
+      fireEvent.load(image)
+      fireEvent[event](image)
+    })
+    expect(screen.getByRole('img')).toBe(image)
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
   it('retains a loaded image when a theme render returns the same URL', async () => {
     vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
     const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)

+ 1 - 0
tsconfig.host.json

@@ -123,6 +123,7 @@
     "apps/web/tests/chat-continuous-conversation.e2e.ts",
     "apps/web/tests/composer-tab-geometry.e2e.ts",
     "apps/web/tests/complex-history.perf.ts",
+    "apps/web/tests/diagram-preview.perf.ts",
     "apps/web/tests/pwsh-terminal.e2e.ts",
     "apps/web/tests/workflow-run.e2e.ts",
     "apps/web/stress-tests/reasoning-chunks.stress.ts",