perf(web-read-card): lazy-load read grammars and guard empty-window copy

Only TypeScript, shell, and JSON grammars load at Web boot; the read card's
wider langFromPath extension set loads through dynamic imports on first use,
so a session that never opens a read card in one of those languages avoids
~1.6 MB of grammar modules and their synchronous init. ReadBlock/CodeBlock
re-render on grammar-load via useSyncExternalStore, picking up highlighting
once the grammar registers. ReadBlock hides the copy control on an empty
window (a successful read of an empty file settles to lines: [] with
card:'read'), matching TerminalBlock so it cannot wipe the clipboard.
This commit is contained in:
Chinesezjc
2026-07-30 21:28:38 +08:00
parent ca6af9cd7c
commit e98f458852
8 changed files with 216 additions and 71 deletions

View File

@@ -9,10 +9,15 @@
// two cards collapse a long body at the same place. Colors resolve through
// --shiki-*/--dsw-* tokens.
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import { highlightLines, type HighlightSpan } from './markdown/highlight.ts'
import {
grammarLoadCount,
highlightLines,
subscribeGrammarLoaded,
type HighlightSpan,
} from './markdown/highlight.ts'
import css from './ReadBlock.module.css'
/**
@@ -75,9 +80,14 @@ export function ReadBlock({
// Highlighting the whole window in one call (not line by line) keeps grammar
// context across lines — a multi-line string or comment stays one construct.
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
// Re-render when a lazy grammar finishes loading, so a read card 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.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
// unknown/absent language, when every line renders as bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang])
// unknown/absent (or not-yet-loaded) language, when every line renders as
// bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
@@ -131,16 +141,15 @@ export function ReadBlock({
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* No empty-window guard around the copy control, unlike TerminalBlock
(which hides copy on empty output): a read card is reached only for
a settled read whose result view declares `card:'read'`, and the
read tool projects that view solely for a parsed envelope with a
line window. An empty or non-envelope result falls back to the
generic card upstream (readCardModel returns null), so `lines` is
never empty here — the branch TerminalBlock needs cannot arise. */}
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
guard: a successful read of an empty file returns lines: [] with
card:'read', so this branch is reachable, and copying then would
wipe the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
</div>
<div className={css.body}>

View File

@@ -4,10 +4,10 @@
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -21,7 +21,11 @@ export interface CodeBlockProps {
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, 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.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)

View File

@@ -5,12 +5,17 @@
* theme package's token sheets as `--shiki-*` custom properties (light and
* dark blocks), never here — the repo's tokens-only styling rule.
*
* Grammars are the set the harness actually renders: the markdown-fence and
* `run_code` languages (TypeScript, shell, JSON) plus the file-extension
* language hints the read tool's `langFromPath` emits (`packages/fs/tool-fs`),
* so a read card highlights the same source, config, and markup extensions the
* backend recognizes. An unknown or absent language falls back to plain text
* (no highlighting, still monospace) — never an error.
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
* JSON) load into the singleton at boot — the set every session renders. The
* read card's wider extension set (the file-extension language hints the read
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
* markup, …) is imported lazily and registered the first time such a language
* is requested, so a session that never opens a read card in one of those
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
* init. The first render of a lazy language falls back to plain text while its
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
* with highlighting. An unknown or absent language falls back to plain text (no
* highlighting, still monospace) — never an error.
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
@@ -18,55 +23,66 @@ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import langPython from '@shikijs/langs/python'
import langRuby from '@shikijs/langs/ruby'
import langGo from '@shikijs/langs/go'
import langRust from '@shikijs/langs/rust'
import langJava from '@shikijs/langs/java'
import langC from '@shikijs/langs/c'
import langCpp from '@shikijs/langs/cpp'
import langCsharp from '@shikijs/langs/csharp'
import langKotlin from '@shikijs/langs/kotlin'
import langSwift from '@shikijs/langs/swift'
import langPhp from '@shikijs/langs/php'
import langYaml from '@shikijs/langs/yaml'
import langToml from '@shikijs/langs/toml'
import langIni from '@shikijs/langs/ini'
import langMarkdown from '@shikijs/langs/markdown'
import langMdx from '@shikijs/langs/mdx'
import langHtml from '@shikijs/langs/html'
import langCss from '@shikijs/langs/css'
import langScss from '@shikijs/langs/scss'
import langLess from '@shikijs/langs/less'
import langSql from '@shikijs/langs/sql'
import langXml from '@shikijs/langs/xml'
import langLua from '@shikijs/langs/lua'
import type { HighlighterCore } from 'shiki/core'
import type { CSSProperties } from 'react'
/**
* Grammars the singleton registers; each entry's own `name` is the id
* `codeToTokens`/`codeToHtml` resolve. The TypeScript grammar embeds JS/JSX/TSX,
* so the JS-family fence aliases resolve to it rather than a separate grammar.
*/
const LANGS = [
langTs, langBash, langJson,
langPython, langRuby, langGo, langRust, langJava,
langC, langCpp, langCsharp, langKotlin, langSwift, langPhp,
langYaml, langToml, langIni,
langMarkdown, langMdx, langHtml, langCss, langScss, langLess,
langSql, langXml, langLua,
]
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
type LangModule = { default: typeof langTs }
/**
* Language ids (and aliases) the singleton registers; everything else renders
* Grammars the singleton loads at boot; each entry's own `name` is the id
* `codeToTokens`/`codeToHtml` resolve. The TypeScript grammar embeds JS/JSX/TSX,
* so the JS-family fence aliases resolve to it rather than a separate grammar.
* The read card's wider set loads lazily through {@link LAZY_GRAMMARS}.
*/
const LANGS = [langTs, langBash, langJson]
/**
* The read card's extension grammars, each behind a dynamic import so its
* module stays out of the boot chunk until a read of that language renders.
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
* hands the whole array to `loadLanguageSync`, which registers each entry
* (including embedded sub-grammars). The three boot grammars are absent —
* already loaded, so no alias value ever points at a missing entry here.
*/
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
['python', () => import('@shikijs/langs/python')],
['ruby', () => import('@shikijs/langs/ruby')],
['go', () => import('@shikijs/langs/go')],
['rust', () => import('@shikijs/langs/rust')],
['java', () => import('@shikijs/langs/java')],
['c', () => import('@shikijs/langs/c')],
['cpp', () => import('@shikijs/langs/cpp')],
['csharp', () => import('@shikijs/langs/csharp')],
['kotlin', () => import('@shikijs/langs/kotlin')],
['swift', () => import('@shikijs/langs/swift')],
['php', () => import('@shikijs/langs/php')],
['yaml', () => import('@shikijs/langs/yaml')],
['toml', () => import('@shikijs/langs/toml')],
['ini', () => import('@shikijs/langs/ini')],
['markdown', () => import('@shikijs/langs/markdown')],
['mdx', () => import('@shikijs/langs/mdx')],
['html', () => import('@shikijs/langs/html')],
['css', () => import('@shikijs/langs/css')],
['scss', () => import('@shikijs/langs/scss')],
['less', () => import('@shikijs/langs/less')],
['sql', () => import('@shikijs/langs/sql')],
['xml', () => import('@shikijs/langs/xml')],
['lua', () => import('@shikijs/langs/lua')],
])
/**
* Language ids (and aliases) the highlighter accepts; 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. Keys cover both
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
* the read tool's `langFromPath` emits, so both callers resolve the same
* grammars. The JS family maps to the TypeScript grammar (which embeds it),
* unchanged from when this was the only non-shell/JSON grammar.
* unchanged from when this was the only non-shell/JSON grammar. A value not in
* {@link LANGS} names a {@link LAZY_GRAMMARS} entry loaded on first use.
*/
const LANG_ALIASES = new Map<string, string>([
['typescript', 'typescript'],
@@ -132,6 +148,62 @@ function highlighter(): HighlighterCore {
return singleton
}
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
const requested = new Set<string>()
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
const listeners = new Set<() => void>()
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
let loadCount = 0
/**
* Subscribe to lazy-grammar load completions; `listener` fires after a
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
* caller that rendered its plain fallback while the grammar loaded can
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
* @param listener - invoked (no args) on each grammar-load completion.
* @returns a disposer that removes the listener.
*/
export function subscribeGrammarLoaded(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* The lazy-grammar load counter — a value that changes on every load, so a
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
* registers. Opaque: only its identity across renders matters.
* @returns the current load count.
*/
export function grammarLoadCount(): number {
return loadCount
}
/**
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
* synchronously; a lazy grammar not yet loaded starts its import (once) and
* reports not-ready, so the caller renders plain until a
* {@link subscribeGrammarLoaded} listener fires.
* @param resolved - the grammar id an alias resolved to.
* @returns whether the grammar is registered and ready to tokenize now.
*/
function ensureGrammar(resolved: string): boolean {
const load = LAZY_GRAMMARS.get(resolved)
// A boot grammar (already registered) has no lazy loader; it is always ready.
if (load === undefined) return true
if (highlighter().getLoadedLanguages().includes(resolved)) return true
if (!requested.has(resolved)) {
requested.add(resolved)
void load().then((mod) => {
highlighter().loadLanguageSync(mod.default)
loadCount += 1
for (const listener of listeners) listener()
})
}
return false
}
// 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
@@ -144,14 +216,17 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
* renders its plain fallback.
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
* for this call and loads in the background; subscribe with
* {@link onGrammarLoaded} to re-highlight once it registers.
* @param code - the source text.
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
* @returns the highlighted HTML, or `undefined` for unknown languages.
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}
@@ -179,11 +254,12 @@ export interface HighlightSpan {
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
* @returns one entry per source line (each an array of runs), or `undefined` for unknown languages.
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
// third, empty line the caller's own line array does not carry. Drop that

View File

@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
expect(highlightToHtml('x', undefined)).toBeUndefined()
})
// Every read-tool language hint whose grammar loads lazily (the boot set —
// ts/js/bash/sh/json — is covered above). Touching each one drives its own
// dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
const LAZY_ALIASES = [
'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
'xml', 'lua',
]
it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
// First touch returns the plain fallback (undefined) and starts the import.
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
// Once every grammar has registered, the same call highlights.
await vi.waitFor(() => {
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
})
})
})
describe('CodeBlock', () => {

View File

@@ -10,7 +10,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
import { highlightLines } from '../src/markdown/highlight.ts'
import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
afterEach(cleanup)
@@ -71,6 +71,25 @@ describe('highlightLines', () => {
expect(highlightLines('x', 'cobol')).toBeUndefined()
expect(highlightLines('x', undefined)).toBeUndefined()
})
it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
// A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
// not, so the first call renders plain and imports the grammar, and a
// subscriber fires once it registers, after which the same call highlights.
let notified = 0
const stop = subscribeGrammarLoaded(() => { notified += 1 })
// First touch: grammar not loaded yet, so plain fallback while it imports.
expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
// The import + loadLanguageSync resolve on a microtask; wait for the notify.
await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
expect(grammarLoadCount()).toBeGreaterThan(0)
const result = highlightLines('def f(): pass', 'py')
expect(result).not.toBeUndefined()
// `def` is a python keyword and carries a --shiki-* color once highlighted.
const keyword = result!.flat().find(span => span.text === 'def')
expect(keyword?.style?.color).toContain('var(--shiki-')
stop()
})
})
describe('ReadBlock rows', () => {
@@ -212,4 +231,11 @@ describe('ReadBlock copy', () => {
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
})
it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
// A successful read of an empty file settles to lines: [] with card:'read',
// so this branch is reachable; copying then would clear the clipboard.
const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
})