Viewport-activated syntax highlighting
视口激活的语法高亮
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](../
English
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 and incremental streaming highlighter 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.
中文
问题
长对话会挂载远在可见视口之外的代码围栏和读取卡片。预先高亮会在挂载时 tokenize 每个受支持的块并创建全部 token span,使主线程工作量和 DOM 大小随完整渲染历史增长,而不是随读者能看到的代码增长。Shiki 选型与流式增量高亮分别约束初始化成本和重复处理前缀的成本,但都无法避免不可见历史首次发生的逐块 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、复制文本和定稿输出均保持不变。