Browse Source

fix(ui-primitives): prototype-safe alias lookup; pre-warm shiki off the render path

Responding to ds-review-bot round 2 on #662:

- LANG_ALIASES is a Map: an assistant-authored fence label like
  constructor or __proto__ now misses (plain render) instead of resolving
  an inherited object property and crashing shiki mid-conversation. Test
  sweeps the inherited-key labels.
- The singleton is pre-warmed in a deferred task at plugin boot (the
  ~120-175ms engine+grammar construction long task moves off the first
  finalized fence's render); the lazy path remains the correctness
  fallback, and unref keeps non-browser imports from pinning the loop.

Agent Note updated (both languages).
Tianyi Cui 2 months ago
parent
commit
79e72eb736

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.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
-2026-07-26-web-syntax-highlighting-shiki.md: 79ad2153b8883fda92205dada300fd194834129b
-2026-07-26-web-syntax-highlighting-shiki.zh.md: 4cb3f0ceadebc4837108463c149262bf8e36f93d
+2026-07-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12

+ 1 - 1
.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md

@@ -15,7 +15,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
 **Shiki in its synchronous fine-grained form, as one `ui-primitives` singleton, themed exclusively through CSS custom properties.**
 
 - **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
-- **Singleton**: `ui-primitives/src/markdown/highlight.ts` lazily creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
+- **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
 - **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
 - **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md

@@ -15,7 +15,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
 **采用同步细粒度形态的 shiki,作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。**
 
 - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
-- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 惰性创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
+- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
 - **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
 - **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
 

+ 31 - 17
packages/client/ui-primitives/src/markdown/highlight.ts

@@ -18,21 +18,26 @@ import langBash from '@shikijs/langs/shellscript'
 import langJson from '@shikijs/langs/json'
 import type { HighlighterCore } from 'shiki/core'
 
-/** Language ids (and aliases) the singleton registers; everything else renders plain. */
-const LANG_ALIASES: Record<string, string> = {
-  typescript: 'typescript',
-  ts: 'typescript',
-  tsx: 'typescript',
-  javascript: 'typescript',
-  js: 'typescript',
-  shellscript: 'shellscript',
-  bash: 'shellscript',
-  sh: 'shellscript',
-  shell: 'shellscript',
-  zsh: 'shellscript',
-  json: 'json',
-  jsonc: 'json',
-}
+/**
+ * Language ids (and aliases) the singleton registers; everything else renders
+ * plain. A Map, not an object: fence info strings are assistant-authored, so
+ * a label like `constructor` or `__proto__` must miss instead of resolving an
+ * inherited property and crashing the renderer inside shiki.
+ */
+const LANG_ALIASES = new Map<string, string>([
+  ['typescript', 'typescript'],
+  ['ts', 'typescript'],
+  ['tsx', 'typescript'],
+  ['javascript', 'typescript'],
+  ['js', 'typescript'],
+  ['shellscript', 'shellscript'],
+  ['bash', 'shellscript'],
+  ['sh', 'shellscript'],
+  ['shell', 'shellscript'],
+  ['zsh', 'shellscript'],
+  ['json', 'json'],
+  ['jsonc', 'json'],
+])
 
 /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
 const cssVariablesTheme = createCssVariablesTheme({
@@ -43,7 +48,7 @@ const cssVariablesTheme = createCssVariablesTheme({
 
 let singleton: HighlighterCore | undefined
 
-/** The lazily-created synchronous highlighter (one instance per document). */
+/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */
 function highlighter(): HighlighterCore {
   singleton ??= createHighlighterCoreSync({
     themes: [cssVariablesTheme],
@@ -53,6 +58,15 @@ function highlighter(): HighlighterCore {
   return singleton
 }
 
+// Engine + grammar construction costs a long task (~120-175ms); building it
+// during the first finalized fence's render would jank exactly when a stream
+// completes. Warm the singleton in a deferred task at module load (= plugin
+// boot) instead; the lazy path above stays as the correctness fallback for a
+// fence that renders before the timer fires. `unref` (Node-only) keeps a
+// non-browser import from pinning the event loop.
+const warmupTimer = setTimeout(() => { highlighter() }, 0)
+;(warmupTimer as { unref?: () => void }).unref?.()
+
 /**
  * Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
  * when `lang` maps to a registered grammar; `undefined` means the caller
@@ -62,7 +76,7 @@ function highlighter(): HighlighterCore {
  * @returns the highlighted HTML, or `undefined` for unknown languages.
  */
 export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
-  const resolved = lang === undefined ? undefined : LANG_ALIASES[lang.toLowerCase()]
+  const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
   if (resolved === undefined) return undefined
   return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
 }

+ 9 - 0
packages/client/ui-primitives/tests/markdown.spec.tsx

@@ -64,6 +64,15 @@ describe('MarkdownText', () => {
     expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
   })
 
+  it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
+    for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
+      const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
+      expect(container.querySelector('pre.shiki')).toBeNull()
+      expect(container.querySelector('pre code')?.textContent).toContain('code body')
+      unmount()
+    }
+  })
+
   it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
     const empty = render(<MarkdownText text={'```\n```'} />)
     expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')