浏览代码

perf(ui-primitives): defer offscreen syntax highlighting

imccyu 3 周之前
父节点
当前提交
faa61ada74

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/architecture/2026-08-31-viewport-activated-syntax-highlighting.md
+2026-08-31-viewport-activated-syntax-highlighting.md: 050c4e50240f9e1d1eb4f2708df6bc93c3a1faf1
+2026-08-31-viewport-activated-syntax-highlighting.zh.md: abd02161ae00d8bdd31863826565175e1d8fddf9

+ 41 - 0
.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md

@@ -0,0 +1,41 @@
+# Agent Note: Viewport-activated syntax highlighting
+
+Status: implemented
+
+English | [中文](2026-08-31-viewport-activated-syntax-highlighting.zh.md)
+
+## Problem
+
+A long conversation mounts code fences and read cards far outside the visible viewport. Eager highlighting tokenizes every supported block and creates every token span during that mount, so main-thread work and DOM size scale with the whole rendered history rather than the code the reader can see. The [Shiki selection](../process/2026-07-26-web-syntax-highlighting-shiki.md) and [incremental streaming highlighter](../feature/2026-08-20-web-streaming-fence-highlight.md) bound initialization and repeated prefix work, but neither avoids the first per-block tokenization for unseen history.
+
+## Decision
+
+`useViewportHighlighting` owns one shared `IntersectionObserver` for syntax-highlightable `CodeBlock` and `ReadBlock` instances. A supported block renders its existing plain-text arm until its root first intersects the viewport. An absent or unsupported language never registers with the observer. A browser without `IntersectionObserver` activates highlighting after mount so the capability still works.
+
+The first intersecting entry removes its target from the observer and activates that component for the rest of its lifetime. Leaving the viewport never returns it to plain text. This one-way transition avoids repeated tokenization, token-DOM construction, and visual churn while scrolling. The shared observer disconnects when no inactive registered blocks remain.
+
+`CodeBlock` gates both settled `highlightToHtml` calls and streaming `StreamingHighlightSession` creation. It starts an activated stream from the current accumulated source, then retains the existing incremental tokenizer and React line caches. `ReadBlock` gates `highlightLines` while retaining its line rows and gutter. The plain and highlighted arms keep the same source text, code font, padding, wrapping, and line height; Shiki's color, bold, italic, and underline token styles remain unchanged.
+
+The module-level Shiki singleton warm-up remains eager. Viewport activation defers code-block content tokenization and token-span construction, not the fixed boot-grammar warm-up or the plain content DOM.
+
+## Testing
+
+The focused jsdom test replaces the process-global `IntersectionObserver`, mounts several code surfaces, and proves that non-intersecting and unsupported blocks remain plain, intersecting blocks share one observer, leaving the viewport does not remove highlighting, and an activated block continues to highlight changed source. It also covers read-card activation and observer disposal. Each test restores the global and unmounts every component, so the module-level registry cannot leak registrations into another case.
+
+Existing component tests run without `IntersectionObserver` and therefore cover the immediate fallback together with the established Shiki output, font styles, streaming caches, and plain-language behavior. Browser geometry is not measured by this unit suite; geometry stability relies on the unchanged shared typography and box styles of the plain and highlighted arms.
+
+## Alternatives considered
+
+**Deactivate highlighting when a block leaves the viewport.** This can reclaim token DOM from blocks already viewed, but scrolling repeatedly rebuilds the same token tree, discards streaming caches, and changes visible presentation at both viewport edges. One-way activation pays the cost at most once per mounted block.
+
+**Drop bold and italic token styles to make every token use identical font metrics.** This weakens syntax presentation, especially for highlighted Markdown, and is unnecessary for the chosen lifecycle: both render arms already use the same code font and fixed line height. Shiki's existing token styles remain intact.
+
+**Pin a measured pixel height during activation.** A fixed measurement becomes stale when a streaming fence grows or responsive wrapping changes, and can clip content or introduce an inner vertical scrollbar. The plain arm stays in normal flow instead of adding measurement state.
+
+**Virtualize complete code blocks or retain only a token window.** This can also bound DOM after a reader has visited every block, but it changes selection, copy, scroll anchoring, and streaming-cache ownership. Viewport activation removes unseen work without changing those behaviors.
+
+## Consequences
+
+Supported code that is never viewed incurs no content tokenization and creates no token spans. The first viewport intersection pays the normal synchronous highlight cost; a lazily imported grammar may keep the block plain until its existing load notification arrives. Activated blocks retain their highlighted DOM when scrolled away, so memory use grows with blocks the reader has visited rather than shrinking with the current viewport.
+
+The optimization is local to presentation. Markdown parsing, Shiki grammar selection and styling, stream-tail tokenization, copy text, and settled output remain unchanged.

+ 41 - 0
.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: 视口激活的语法高亮
+
+Status: implemented
+
+[English](2026-08-31-viewport-activated-syntax-highlighting.md) | 中文
+
+## 问题
+
+长对话会挂载远在可见视口之外的代码围栏和读取卡片。预先高亮会在挂载时 tokenize 每个受支持的块并创建全部 token span,使主线程工作量和 DOM 大小随完整渲染历史增长,而不是随读者能看到的代码增长。[Shiki 选型](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md)与[流式增量高亮](../feature/2026-08-20-web-streaming-fence-highlight.zh.md)分别约束初始化成本和重复处理前缀的成本,但都无法避免不可见历史首次发生的逐块 tokenize。
+
+## 决策
+
+`useViewportHighlighting` 为可进行语法高亮的 `CodeBlock` 和 `ReadBlock` 实例持有一个共享的 `IntersectionObserver`。受支持的块在根元素首次与视口相交之前渲染既有的纯文本臂。语言缺失或不受支持时不会向 observer 注册。浏览器不提供 `IntersectionObserver` 时会在挂载后激活高亮,使该能力仍然可用。
+
+首个相交条目会从 observer 中移除自己的目标,并在该组件余下的生命周期里保持激活。离开视口不会恢复为纯文本。这种单向转换避免滚动时反复 tokenize、创建 token DOM 和发生视觉切换。不存在尚未激活的注册块时,共享 observer 会断开。
+
+`CodeBlock` 同时控制定稿态的 `highlightToHtml` 调用和流式 `StreamingHighlightSession` 的创建。流式块激活时从当前累积源码开始,随后保留既有的增量 tokenizer 与 React 行缓存。`ReadBlock` 控制 `highlightLines`,同时保留其行和行号槽。纯文本臂与高亮臂使用相同的源码文本、代码字体、内边距、换行规则和行高;Shiki 现有的 token 颜色、粗体、斜体和下划线样式保持不变。
+
+模块级 Shiki 单例仍然预先预热。视口激活延迟的是代码块内容的 tokenize 与 token span 创建,不是固定的启动语法预热或纯文本内容 DOM。
+
+## Testing
+
+聚焦的 jsdom 测试替换进程全局 `IntersectionObserver`,挂载多个代码表面,并证明未相交和不受支持的块保持纯文本、相交块共用一个 observer、离开视口不会移除高亮,且已激活的块会继续高亮变化后的源码。测试还覆盖读取卡片激活和 observer 释放。每个测试都会恢复全局值并卸载全部组件,因此模块级注册表不会把注册项泄漏到其他用例。
+
+既有组件测试在没有 `IntersectionObserver` 的环境中运行,因此同时覆盖立即回退路径,以及既有 Shiki 输出、字体样式、流式缓存和纯文本语言行为。该单元测试套件不测量浏览器几何尺寸;几何稳定性依赖纯文本臂与高亮臂不变的共享字体排印和盒模型样式。
+
+## 曾考虑的替代方案
+
+**块离开视口时停用高亮。** 这可以回收已经看过的块所占用的 token DOM,但滚动会反复重建同一棵 token 树、丢弃流式缓存,并在视口两端改变可见呈现。单向激活使每个已挂载块最多支付一次成本。
+
+**移除粗体和斜体 token 样式,使所有 token 使用完全相同的字体度量。** 这会削弱语法呈现,尤其影响高亮后的 Markdown;所选生命周期也不需要这一取舍,因为两个渲染臂已经使用相同的代码字体和固定行高。Shiki 现有 token 样式保持不变。
+
+**在激活时锁定测量得到的像素高度。** 当流式围栏增长或响应式换行变化时,固定测量会陈旧,并可能裁切内容或引入内部纵向滚动条。纯文本臂继续处于正常文档流中,不增加测量状态。
+
+**虚拟化完整代码块,或仅保留一个 token 窗口。** 这也能在读者访问所有块之后限制 DOM,但会改变文本选择、复制、滚动锚定与流式缓存的归属。视口激活消除不可见工作,同时不改变这些行为。
+
+## 后果
+
+从未进入视口的受支持代码不会发生内容 tokenize,也不会创建 token span。首次与视口相交时支付普通的同步高亮成本;若语法采用懒加载,代码块可能继续保持纯文本,直到既有的加载通知到达。已激活的块滚出视口后会保留高亮 DOM,因此内存占用随读者访问过的块增长,而不会随当前视口缩减。
+
+该优化仅作用于呈现层。Markdown 解析、Shiki 语法选择与样式、流式尾行 tokenize、复制文本和定稿输出均保持不变。

+ 9 - 3
packages/client/ui-primitives/src/ReadBlock.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
+import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
 import clsx from 'clsx'
 import { FoldToggle } from './FoldToggle.tsx'
 import { writeClipboard } from './clipboard.ts'
@@ -8,6 +8,7 @@ import {
   subscribeGrammarLoaded,
   type HighlightSpan,
 } from './markdown/highlight.ts'
+import { useViewportHighlighting } from './markdown/useViewportHighlighting.ts'
 import css from './ReadBlock.module.css'
 
 /**
@@ -72,6 +73,8 @@ export function ReadBlock({
   maxLines = DEFAULT_READ_MAX_LINES,
   className,
 }: ReadBlockProps) {
+  const rootRef = useRef<HTMLDivElement>(null)
+  const highlighting = useViewportHighlighting(rootRef, lang)
   // Whole-window highlighting preserves multiline grammar context; copy uses
   // the same text without gutter or banner chrome.
   const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
@@ -79,7 +82,10 @@ export function ReadBlock({
   // plain text while its language's grammar imported picks up highlighting. The
   // snapshot value is opaque; only its change across renders drives the memo.
   const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
-  const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
+  const highlighted = useMemo(
+    () => highlighting ? highlightLines(raw, lang) : undefined,
+    [highlighting, raw, lang, loaded],
+  )
   const [expanded, setExpanded] = useState(false)
   const [copied, setCopied] = useState(false)
 
@@ -114,7 +120,7 @@ export function ReadBlock({
     [line, highlighted?.[index]])
 
   return (
-    <div className={clsx(css.block, className)} data-read="">
+    <div ref={rootRef} className={clsx(css.block, className)} data-read="">
       <div className={css.banner}>
         <div className={css.label}>{label ?? ''}</div>
         <div className={css.action}>

+ 14 - 4
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -6,6 +6,7 @@ import {
   StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
 } from './highlight.ts'
 import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
+import { useViewportHighlighting } from './useViewportHighlighting.ts'
 import css from './CodeBlock.module.css'
 
 export interface CodeBlockProps {
@@ -58,6 +59,8 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
 
 export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
+  const rootRef = useRef<HTMLDivElement>(null)
+  const highlighting = useViewportHighlighting(rootRef, lang)
   // Re-render when a lazy grammar finishes loading, so a fence that showed plain
   // text while its language's grammar imported picks up highlighting. The
   // snapshot value is opaque; only its change across renders drives the memo.
@@ -78,6 +81,12 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL
   } | null>(null)
   const settledRef = useRef(false)
   const streamedBody = useMemo(() => {
+    if (!highlighting) {
+      sessionRef.current = null
+      lineCacheRef.current = null
+      settledRef.current = false
+      return undefined
+    }
     if (streaming !== true) {
       const previous = lineCacheRef.current
       if (previous !== null && previous.code === trimmed && previous.lang === lang) {
@@ -123,12 +132,13 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL
       code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
     }
     return body
-  }, [streaming, trimmed, lang, loaded])
+  }, [streaming, highlighting, trimmed, lang, loaded])
   const html = useMemo(
-    () => (streaming !== true && streamedBody === undefined ? highlightToHtml(trimmed, lang) : undefined),
-    [streaming, streamedBody, trimmed, lang, loaded],
+    () => (highlighting && streaming !== true && streamedBody === undefined
+      ? highlightToHtml(trimmed, lang)
+      : undefined),
+    [streaming, highlighting, streamedBody, trimmed, lang, loaded],
   )
-  const rootRef = useRef<HTMLDivElement>(null)
   const [copied, setCopied] = useState(false)
 
   const onCopy = useCallback(() => {

+ 9 - 0
packages/client/ui-primitives/src/markdown/highlight.ts

@@ -132,6 +132,15 @@ const LANG_ALIASES = new Map<string, string>([
   ['lua', 'lua'],
 ])
 
+/**
+ * Whether a language hint can use the shared syntax highlighter.
+ * @param lang - Language hint from a code surface.
+ * @returns Whether the hint resolves to a supported grammar.
+ */
+export function supportsHighlighting(lang: string | undefined): boolean {
+  return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase())
+}
+
 /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
 const cssVariablesTheme = createCssVariablesTheme({
   name: 'css-variables',

+ 72 - 0
packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts

@@ -0,0 +1,72 @@
+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()
+
+/**
+ * Activate one supported code surface when it first intersects the viewport.
+ * Activation lasts for the component lifetime; browsers without
+ * IntersectionObserver activate immediately.
+ * @param target - Code surface whose plain rendering reserves its geometry.
+ * @param lang - Optional language hint.
+ * @returns Whether this component may build highlighted output.
+ */
+export function useViewportHighlighting(
+  target: RefObject<Element>,
+  lang: string | undefined,
+): boolean {
+  const supported = supportsHighlighting(lang)
+  const [activated, setActivated] = useState(false)
+  const activate = useCallback(() => { 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)
+  }, [activate, activated, supported, target])
+
+  return activated && supported
+}

+ 147 - 0
packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx

@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, render, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ReadBlock } from '../src/ReadBlock.tsx'
+import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import { markdownLabels, readBlockLabels } from './labels.client.ts'
+
+class IntersectionObserverStub {
+  static instances: IntersectionObserverStub[] = []
+
+  readonly observed = new Set<Element>()
+  readonly unobserved = new Set<Element>()
+  disconnected = false
+
+  constructor(private readonly callback: IntersectionObserverCallback) {
+    IntersectionObserverStub.instances.push(this)
+  }
+
+  observe(element: Element): void {
+    this.observed.add(element)
+  }
+
+  unobserve(element: Element): void {
+    this.observed.delete(element)
+    this.unobserved.add(element)
+  }
+
+  disconnect(): void {
+    this.disconnected = true
+    this.observed.clear()
+  }
+
+  takeRecords(): IntersectionObserverEntry[] {
+    return []
+  }
+
+  intersect(element: Element, isIntersecting: boolean): void {
+    this.callback(
+      [{ target: element, isIntersecting } as IntersectionObserverEntry],
+      this as unknown as IntersectionObserver,
+    )
+  }
+}
+
+beforeEach(() => {
+  IntersectionObserverStub.instances = []
+  vi.stubGlobal('IntersectionObserver', IntersectionObserverStub)
+})
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+
+describe('viewport-activated syntax highlighting', () => {
+  it('keeps offscreen blocks plain and permanently activates only intersecting blocks', async () => {
+    const view = render(
+      <>
+        <CodeBlock code="const first = 1" lang="ts" {...markdownLabels.code} />
+        <CodeBlock code="const second = 2" lang="ts" {...markdownLabels.code} />
+        <CodeBlock code="const third = 3" lang="ts" {...markdownLabels.code} />
+      </>,
+    )
+    const blocks = [...view.container.querySelectorAll('.md-code-block')]
+    expect(blocks).toHaveLength(3)
+    expect(IntersectionObserverStub.instances).toHaveLength(1)
+    const observer = IntersectionObserverStub.instances[0]!
+    expect(observer.observed.size).toBe(3)
+    expect(view.container.querySelectorAll('pre.shiki')).toHaveLength(0)
+
+    act(() => { observer.intersect(blocks[0]!, false) })
+    expect(view.container.querySelectorAll('pre.shiki')).toHaveLength(0)
+
+    act(() => {
+      observer.intersect(blocks[0]!, true)
+      observer.intersect(blocks[1]!, true)
+    })
+    await waitFor(() => {
+      expect(blocks[0]!.querySelector('pre.shiki')).not.toBeNull()
+      expect(blocks[1]!.querySelector('pre.shiki')).not.toBeNull()
+    })
+    expect(blocks[2]!.querySelector('pre.shiki')).toBeNull()
+    expect(observer.unobserved.has(blocks[0]!)).toBe(true)
+
+    act(() => { observer.intersect(blocks[0]!, false) })
+    expect(blocks[0]!.querySelector('pre.shiki')).not.toBeNull()
+
+    view.rerender(
+      <>
+        <CodeBlock code="const first = 10" lang="ts" {...markdownLabels.code} />
+        <CodeBlock code="const second = 2" lang="ts" {...markdownLabels.code} />
+        <CodeBlock code="const third = 3" lang="ts" {...markdownLabels.code} />
+      </>,
+    )
+    expect(blocks[0]!.querySelector('pre.shiki')?.textContent).toBe('const first = 10')
+
+    act(() => { observer.intersect(blocks[2]!, true) })
+    await waitFor(() => { expect(blocks[2]!.querySelector('pre.shiki')).not.toBeNull() })
+    expect(observer.disconnected).toBe(true)
+  })
+
+  it('does not observe an unsupported language', () => {
+    const view = render(
+      <CodeBlock code="IDENTIFICATION DIVISION." lang="cobol" {...markdownLabels.code} />,
+    )
+    expect(view.container.querySelector('pre.shiki')).toBeNull()
+    expect(IntersectionObserverStub.instances).toHaveLength(0)
+  })
+
+  it('releases the shared observer when the last pending block unmounts', () => {
+    const view = render(<CodeBlock code="const pending = true" lang="ts" {...markdownLabels.code} />)
+    const block = view.container.querySelector('.md-code-block')!
+    const observer = IntersectionObserverStub.instances[0]!
+
+    view.unmount()
+
+    expect(observer.unobserved.has(block)).toBe(true)
+    expect(observer.disconnected).toBe(true)
+  })
+
+  it('highlights immediately when IntersectionObserver is unavailable', () => {
+    vi.stubGlobal('IntersectionObserver', undefined)
+    const view = render(<CodeBlock code="const fallback = true" lang="ts" {...markdownLabels.code} />)
+    expect(view.container.querySelector('pre.shiki')).not.toBeNull()
+  })
+
+  it('keeps a read card plain until that card intersects', async () => {
+    const view = render(
+      <ReadBlock
+        label="data.json"
+        lang="json"
+        lines={[{ number: 1, text: '{"ready":true}' }]}
+        totalLines={1}
+        labels={readBlockLabels}
+      />,
+    )
+    const block = view.container.querySelector('[data-read]')!
+    expect(block.querySelectorAll('[class^="_content_"] span')).toHaveLength(0)
+    const observer = IntersectionObserverStub.instances[0]!
+
+    act(() => { observer.intersect(block, true) })
+    await waitFor(() => {
+      expect(block.querySelectorAll('[class^="_content_"] span[style]').length).toBeGreaterThan(1)
+    })
+  })
+})