Long chat replies stream with less stutter: parse each block once (#34821)

* Reduce stuttering while long chat replies stream

* Keep streaming markdown pipeline within lint limits

* Type the streaming parser meter without a cast

* Type the streamed markdown test reveal state

* Preserve markdown render domains when configured syntax cannot reuse discovery
This commit is contained in:
Brennan Benson
2026-10-28 12:56:11 -07:00
committed by GitHub
parent 58a175f928
commit 30bb30e25c
10 changed files with 1098 additions and 74 deletions
@@ -54,6 +54,9 @@ function withFileDestinations(
): CommentMarkdownExtension {
return {
remarkPlugins: [...(visualExtension?.remarkPlugins ?? []), remarkNativeChatFileDestinations],
extraFlowSyntaxMarkers: visualExtension?.remarkPlugins.length
? visualExtension.extraFlowSyntaxMarkers
: [],
sanitizeAttributes: visualExtension?.sanitizeAttributes ?? {},
components: visualExtension?.components ?? {}
}
@@ -104,7 +104,12 @@ describe('NativeChatMarkdown visuals', () => {
it('keeps a mounted visual frame while the rest of the reply streams in', async () => {
const { container, rerender } = render(
withOwner(
<NativeChatMarkdown content={`Intro\n\n${LINE}\n\nMore`} visualMessageId="m1" streaming />
<NativeChatMarkdown
content={`Intro\n\n${LINE}\n\n\`\`\`text\nsettled\n\`\`\`\n\nMore`}
visualMessageId="m1"
growing
streaming
/>
)
)
await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull())
@@ -112,8 +117,19 @@ describe('NativeChatMarkdown visuals', () => {
rerender(
withOwner(
<NativeChatMarkdown
content={`Intro\n\n${LINE}\n\nMore text arriving now`}
content={`Intro\n\n${LINE}\n\n\`\`\`text\nsettled\n\`\`\`\n\nMore text arriving now`}
visualMessageId="m1"
growing
streaming
/>
)
)
rerender(
withOwner(
<NativeChatMarkdown
content={`Intro\n\n${LINE}\n\n\`\`\`text\nsettled\n\`\`\`\n\nMore text arriving now\n\n::orca-visual{file="../invalid.html"}\n\nend`}
visualMessageId="m1"
growing
streaming
/>
)
@@ -3,6 +3,7 @@ import type { ExtraProps } from 'react-markdown'
import type { CommentMarkdownExtension } from '@/components/sidebar/CommentMarkdown'
import {
NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH,
NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER,
isNativeChatVisualFileName
} from '../../../../shared/native-chat/native-chat-visual-directive'
import { NativeChatInlineVisual } from './NativeChatInlineVisual'
@@ -55,6 +56,7 @@ function createVisualExtension(
}
return {
remarkPlugins: [[remarkNativeChatVisuals, nonce]],
extraFlowSyntaxMarkers: [NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER],
sanitizeAttributes: { div: [...NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES] },
components: { div: VisualPlaceholder }
}
@@ -0,0 +1,226 @@
// @vitest-environment happy-dom
import React from 'react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, expect, it, vi } from 'vitest'
import type { Processor } from 'unified'
import type remarkParsePlugin from 'remark-parse'
import type * as ReactMarkdownModule from 'react-markdown'
import type * as MarkdownBlocksModule from './markdown-top-level-blocks'
import CommentMarkdown from './CommentMarkdown'
import {
advanceNativeChatText,
receiveNativeChatText,
type NativeChatTextReveal
} from '../../../../shared/native-chat/transcript/native-chat-text-reveal'
const meter = vi.hoisted((): { legacy: boolean; sources: string[] } => ({
legacy: false,
sources: []
}))
vi.mock('remark-parse', async (original) => {
const { default: plugin } = await original<{ default: typeof remarkParsePlugin }>()
return {
default: function (this: Processor) {
plugin.call(this)
const parse = this.parser!
this.parser = (source, file) => {
meter.sources.push(source)
return parse(source, file)
}
}
}
})
vi.mock('react-markdown', async (original) => {
const real = await original<typeof ReactMarkdownModule>()
function measureRenderParser(this: Processor): void {
const parse = this.parser!
this.parser = (source, file) => {
meter.sources.push(source)
return parse(source, file)
}
}
return {
...real,
default: (props: React.ComponentProps<typeof real.default>) =>
real.default({
...props,
remarkPlugins: [measureRenderParser, ...(props.remarkPlugins ?? [])]
})
}
})
vi.mock('./markdown-top-level-blocks', async (original) => {
const current = await original<typeof MarkdownBlocksModule>()
const legacy = await import('./markdown-top-level-blocks-legacy.test-fixture')
return {
...current,
splitMarkdownTopLevelBlocks: (
...args: Parameters<typeof current.splitMarkdownTopLevelBlocks>
) => {
const result = meter.legacy
? legacy.splitMarkdownTopLevelBlocks(args[0], args[1], args[2])
: current.splitMarkdownTopLevelBlocks(...args)
return result
}
}
})
vi.mock('./CommentMermaidBlock', () => ({ default: () => null }))
beforeAll(async () => {
const warm = render(<CommentMarkdown content="$x$" math />)
await waitFor(() => expect(warm.container.querySelector('.katex')).not.toBeNull())
warm.unmount()
})
afterEach(() => {
cleanup()
meter.legacy = false
meter.sources = []
})
// The production reveal cadence supplies exactly the same prefixes to both renderers.
function sequence(full: string, start: number): string[] {
let state: NativeChatTextReveal = { source: full.slice(0, start), shown: start, arrivals: [] }
let nextArrival = 0
const result = [state.source]
for (let frame = 0; frame <= 84; frame++) {
const now = (frame * 1000) / 60
while (nextArrival <= Math.min(now, 1008)) {
const end = Math.min(
full.length,
start + Math.ceil(((full.length - start) * (nextArrival + 48)) / 1056)
)
state = receiveNativeChatText(state, full.slice(0, end), true, false, nextArrival)
nextArrival += 48
}
state = advanceNativeChatText(state, now)
const text = state.source.slice(0, state.shown)
if (text !== result.at(-1)) {
result.push(text)
}
}
if (result.at(-1) !== full) {
result.push(full)
}
return result
}
const prose =
'The implementation reads **stable values** and keeps the same behavior for every user. '.repeat(
650
)
const fence = `\`\`\`text\n${'const result = readCurrentValue(input);\n'.repeat(1400)}\`\`\``
const list = Array.from(
{ length: 650 },
(_, i) => `- Item ${i}: preserve **current behavior** and report the measured result.\n`
).join('')
const code = ({ children, sourceLine }: { children?: React.ReactNode; sourceLine?: number }) => (
<pre data-source-line={sourceLine}>{children}</pre>
)
const exists = () => true
it.each([
['short', prose.slice(0, 800), 0],
['paragraph', prose.slice(0, 50000), 40000],
['fence', fence, 40000],
['list', list, 35000],
['settled fence and tail', `${fence}\n\n${prose.slice(0, 800)}`, fence.length + 2],
[
'backward boundaries',
'Title\n===\n\n- first\n\n- second\n\n continued\n\n| a | b |\n| - | - |\n| 1 | 2 |\n',
0
],
[
'global constructs',
'See [docs][ref].\n\nMore.[^1]\n\n[ref]: https://example.com\n\n[^1]: note\n\n<details>\n\n**hidden**\n\n</details>\n<script>bad()</script>\n<custom>text</custom>',
0
],
[
'math and repair',
'Before **bold** and [link](https://example.com).\n\n$x^2$\n\n$$\nx+y\n$$\n\n\\[z\\]\n\n```math\nx+1\n```\n\n~~~math\ny\n~~~\n\n$unfinished **tail',
0
],
[
'positions and links',
'\n\n ```ts\nline one\nline two\n ```\n\n ~~~text\nother\n ~~~\n\nEdited src/a.ts and [file](file:///tmp/a.ts).\n\n中文**标点。**后续\n',
0
],
[
'indented blocks',
'Intro\n\n ```text\n value\n ```\n\n - one\n\n nested code\n\n > quote\n > ```text\n > value\n > ```\n\nnext',
0
],
['internal BOM', 'one\n\n\uFEFFnext\n\n\uFEFF```text\ncode\n```\n\ntail', 0],
[
'line endings',
'one\r\n\r\n > quote\r\n\r\n```text\r\ncode\r\n```\r\n\r\nlast\uFEFF word\r\n',
0
]
])(
'preserves exact DOM and copy text at every reveal update: %s',
(_name, full, start) => {
const old = render(<CommentMarkdown content="" growing />)
const next = render(<CommentMarkdown content="" growing />)
const props = {
math: true,
variant: 'document',
fadeWords: true,
unknownHtmlAsText: true,
allowFileUriLinks: true,
fileLinkExists: exists,
renderCodeBlock: code
} as const
const prefixes = sequence(full, start)
for (const [index, content] of [...prefixes, full].entries()) {
const growing = index < prefixes.length
meter.legacy = true
old.rerender(<CommentMarkdown {...props} content={content} growing={growing} />)
meter.legacy = false
next.rerender(<CommentMarkdown {...props} content={content} growing={growing} />)
expect(next.container.innerHTML, `at ${content.length} characters`).toBe(
old.container.innerHTML
)
expect(next.container.textContent).toBe(old.container.textContent)
}
},
55000
)
it('uses one actual parse for discovery and rendering, then skips a completed fence', () => {
const source = `${fence}\n\nNext`
meter.sources = []
const view = render(<CommentMarkdown content={source} math growing renderCodeBlock={code} />)
expect(meter.sources).toEqual([source])
meter.sources = []
view.rerender(<CommentMarkdown content={`${source} words`} math growing renderCodeBlock={code} />)
expect(meter.sources).toEqual(['Next words'])
view.unmount()
const staticView = render(<CommentMarkdown content="unchanged" math />)
meter.sources = []
staticView.rerender(<CommentMarkdown content="unchanged" math className="wrapper-change" />)
expect(meter.sources).toEqual([])
staticView.unmount()
meter.legacy = true
meter.sources = []
render(<CommentMarkdown content={source} math growing renderCodeBlock={code} />)
expect(meter.sources.reduce((count, text) => count + text.length, 0)).toBeGreaterThan(
source.length
)
})
it('preserves the current cut when the link policy changes', () => {
const full =
'Intro\n\n ```text\n value\n ```\n\n - one\n\n nested code\n\n > quote\n '
const snapshots: string[] = []
for (const legacy of [true, false]) {
meter.legacy = legacy
const view = render(<CommentMarkdown content="" math growing fileLinkExists={exists} />)
for (const content of sequence(full, 0)) {
view.rerender(<CommentMarkdown content={content} math growing fileLinkExists={exists} />)
}
view.rerender(<CommentMarkdown content={full} math growing fileLinkExists={() => false} />)
snapshots.push(view.container.innerHTML)
view.unmount()
}
expect(snapshots[1]).toBe(snapshots[0])
})
@@ -0,0 +1,483 @@
// @vitest-environment happy-dom
import React from 'react'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { Processor } from 'unified'
import type remarkParsePlugin from 'remark-parse'
import type * as ReactMarkdownModule from 'react-markdown'
import type * as BlocksModule from './markdown-top-level-blocks'
import type { Root } from 'mdast'
import CommentMarkdown, { type CommentMarkdownExtension } from './CommentMarkdown'
import { createParseWork, meterPositions, nodeCount } from './markdown-parse-meter.test-fixture'
import { NativeChatMarkdown } from '../native-chat/NativeChatMarkdown'
import { NativeChatVisualOwnerContext } from '../native-chat/native-chat-visual-owner'
import { NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER } from '../../../../shared/native-chat/native-chat-visual-directive'
import { remarkNativeChatVisuals } from '../native-chat/native-chat-visual-markdown-syntax'
const meter = vi.hoisted(
(): { legacy: boolean; work: ReturnType<typeof createParseWork> | null } => ({
legacy: false,
work: null
})
)
vi.mock('remark-parse', async (original) => {
const { default: plugin } = await original<{ default: typeof remarkParsePlugin }>()
return {
default: function (this: Processor) {
plugin.call(this)
const parse = this.parser!
this.parser = (source, file) => {
meter.work?.discovery.push(source)
const tree = parse(source, file)
if (isRoot(tree)) {
meterPositions(tree, () => {
if (meter.work) {
meter.work.shifts++
}
})
}
return tree
}
}
}
})
vi.mock('react-markdown', async (original) => {
const real = await original<typeof ReactMarkdownModule>()
function measureOriginal(this: Processor): void {
const parse = this.parser!
this.parser = (source, file) => {
meter.work?.render.push(source)
return parse(source, file)
}
}
return {
...real,
default: (props: React.ComponentProps<typeof real.default>) =>
real.default({
...props,
remarkPlugins: [measureOriginal, ...(props.remarkPlugins ?? [])]
})
}
})
vi.mock('./markdown-top-level-blocks', async (original) => {
const current = await original<typeof BlocksModule>()
const legacy = await import('./markdown-top-level-blocks-legacy.test-fixture')
return {
...current,
splitMarkdownTopLevelBlocks: (
...args: Parameters<typeof current.splitMarkdownTopLevelBlocks>
) => {
const result = meter.legacy
? legacy.splitMarkdownTopLevelBlocks(args[0], args[1], args[2])
: current.splitMarkdownTopLevelBlocks(...args)
if (meter.work) {
meter.work.blocks = Math.max(meter.work.blocks, result.blocks.length)
}
return result
}
}
})
vi.mock('../native-chat/NativeChatInlineVisual', () => ({
NativeChatInlineVisual: ({ file }: { file: string }) => <div data-visual-file={file} />
}))
vi.mock('../native-chat/use-native-chat-file-link-existence', () => ({
useNativeChatFileLinkExists: () => undefined
}))
vi.mock('./CommentMermaidBlock', () => ({ default: () => null }))
afterEach(() => {
cleanup()
meter.legacy = false
meter.work = null
vi.restoreAllMocks()
vi.useRealTimers()
})
function isRoot(value: unknown): value is Root {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
value.type === 'root' &&
'children' in value &&
Array.isArray(value.children)
)
}
const owner = { target: { kind: 'local' }, sessionId: 's', worktreeId: 'w' } as const
const code = ({ children, sourceLine }: { children?: React.ReactNode; sourceLine?: number }) => (
<pre data-source-line={sourceLine}>{children}</pre>
)
function native(
content: string,
growing = true,
enabled = true,
variant: 'document' | undefined = undefined
) {
return (
<NativeChatVisualOwnerContext.Provider value={enabled ? owner : null}>
<NativeChatMarkdown
content={content}
visualMessageId="m"
growing={growing}
streaming={growing}
variant={variant}
renderCodeBlock={code}
/>
</NativeChatVisualOwnerContext.Provider>
)
}
const directive = (i = 0) => `::orca-visual{file="v${i}.html"}`
const prefix = '```text\nsettled\n```\n\nNext'
it.each([
[
'adjacent',
`${prefix}\n\n${Array.from({ length: 10 }, (_, i) => directive(i)).join('\n')}\n\nend`,
8
],
['separated', `${Array.from({ length: 10 }, (_, i) => directive(i)).join('\n\n')}\n\nend`, 10],
['lazy list', `- a\n ${directive()}\nfoo\n\nbar`, 0],
['lazy quote', `> a\n${directive()}\nfoo\n\nbar`, 1],
['nested', `- item\n\n ${directive()}\n\n> ${directive(1)}\n\nend`, 0],
['fenced marker', `~~~text\n${directive()}\n~~~\n\nend`, 0],
['title CRLF', '::orca-visual{file="v0.html" title="Title"}\r\n\r\nend', 1],
[
'invalid escaped overlength',
`\\${directive()}\n\n::orca-visual{file="../bad.html"}\n\n::orca-visual{file="v.html" title="${'x'.repeat(520)}"}\n\nend`,
0
],
[
'chart prose math CJK',
`${directive()}\n\n中文**标点。** $x+1$ and **tail\n\n[ref]: https://example.com`,
1
],
[
'BOM and sliced context',
`one\n\n\uFEFFnext\n\n ~~~text\n value\n ~~~\n\n${directive()}\n\nend`,
1
]
])(
'retains exact native DOM/text at every filtered prefix and completion: %s',
(_name, full, mounts) => {
const old = render(native(''))
const next = render(native(''))
for (let end = 0; end <= full.length + 1; end++) {
const content = full.slice(0, end)
meter.legacy = true
old.rerender(native(content, end <= full.length))
meter.legacy = false
next.rerender(native(content, end <= full.length))
expect(next.container.innerHTML, `prefix ${end}`).toBe(old.container.innerHTML)
expect(next.container.textContent).toBe(old.container.textContent)
}
expect(next.container.querySelectorAll('[data-visual-file]')).toHaveLength(mounts)
if (_name === 'adjacent') {
expect(next.container.textContent).toContain(directive(9))
}
},
55000
)
it('preserves historical cuts across config, replacement, completion, re-entry and remount', () => {
const snapshots: string[][] = []
for (const legacy of [true, false]) {
meter.legacy = legacy
let view = render(native(prefix, true, false))
const results: string[] = []
const steps: [string, boolean, boolean, 'document' | undefined][] = [
[prefix, true, true, undefined],
[prefix, true, false, undefined],
[prefix, true, true, 'document'],
[`${prefix}\n\n${directive()}\n\nMore`, true, true, 'document'],
[`${prefix}\n\n${directive()}\n\nMore`, false, true, 'document'],
[`${prefix}\n\n${directive()}\n\nMore words`, true, true, 'document'],
['replacement', true, true, undefined],
[directive(), true, true, undefined],
[directive(), false, true, undefined],
[`${prefix}\n\n::orca-visual{file="v`, true, true, undefined],
[`${prefix}\n\n::orca-visual{file="v`, false, true, undefined],
['', true, true, undefined],
['one\n\n\uFEFFtail', true, true, undefined],
[prefix, true, true, undefined]
]
for (const step of steps) {
view.rerender(native(...step))
results.push(view.container.innerHTML)
}
view.unmount()
view = render(native(`${directive()}\n\nend`))
results.push(view.container.innerHTML)
view.unmount()
view = render(native(`${directive()}\n\nend`, false))
results.push(view.container.innerHTML)
view.unmount()
snapshots.push(results)
}
expect(snapshots[1]).toEqual(snapshots[0])
})
it('defaults unknown ordinary-node syntax to legacy cuts, but permits image-only plugins', () => {
function extraSyntax(this: Processor): void {
const parse = this.parser!
this.parser = (source, file) => parse(source.replaceAll(' / ', '\n\n\n'), file)
}
const unknown: CommentMarkdownExtension = {
remarkPlugins: [extraSyntax],
sanitizeAttributes: {},
components: {}
}
const outputs: string[] = []
for (const legacy of [true, false]) {
meter.legacy = legacy
const view = render(<CommentMarkdown content="one / two" growing extension={unknown} />)
outputs.push(view.container.innerHTML)
view.unmount()
}
expect(outputs[1]).toBe(outputs[0])
meter.legacy = false
meter.work = createParseWork()
render(
<CommentMarkdown
content={prefix}
growing
extension={{ remarkPlugins: [], sanitizeAttributes: {}, components: {} }}
renderCodeBlock={code}
/>
)
expect(meter.work.discovery).toEqual([prefix])
expect(meter.work.render).toEqual([])
})
it('keeps selected fade nodes and their clocks through a declined tail and completion', () => {
vi.useFakeTimers()
const extension: CommentMarkdownExtension = {
remarkPlugins: [[remarkNativeChatVisuals, 'n']],
extraFlowSyntaxMarkers: [NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER],
sanitizeAttributes: {},
components: {}
}
const view = render(
<CommentMarkdown
content={`${prefix}\n\nselected words`}
growing
fadeWords
extension={extension}
variant="document"
renderCodeBlock={code}
/>
)
const fence = view.container.querySelector('pre')
const word = [...view.container.querySelectorAll('[data-word]')].find(
(node) => node.textContent === 'selected'
)!
const range = document.createRange()
range.selectNodeContents(word)
document.getSelection()?.addRange(range)
document.dispatchEvent(new Event('selectionchange'))
act(() => vi.advanceTimersByTime(150))
const content = `${prefix}\n\nselected words\n\n${directive()}\n\nnew words`
view.rerender(
<CommentMarkdown
content={content}
growing
fadeWords
extension={extension}
variant="document"
renderCodeBlock={code}
/>
)
expect(view.container.querySelector('pre')).toBe(fence)
expect(word.isConnected).toBe(true)
act(() => vi.advanceTimersByTime(150))
expect(word.isConnected).toBe(true)
expect(
[...view.container.querySelectorAll('[data-word]')].some((node) => node.textContent === 'new')
).toBe(true)
view.rerender(
<CommentMarkdown
content={content}
fadeWords
extension={extension}
variant="document"
renderCodeBlock={code}
/>
)
expect(document.getSelection()?.toString()).toBe('selected')
document.getSelection()?.removeAllRanges()
document.dispatchEvent(new Event('selectionchange'))
act(() => vi.advanceTimersByTime(1))
expect(word.isConnected).toBe(false)
})
function installWorkMeters(): void {
const clone = globalThis.structuredClone
vi.spyOn(globalThis, 'structuredClone').mockImplementation(<T,>(value: T): T => {
if (meter.work && isRoot(value)) {
meter.work.cloneNodes += nodeCount(value)
meter.work.cloneBytes += JSON.stringify(value).length
}
return clone(value)
})
const includes = String.prototype.includes
vi.spyOn(String.prototype, 'includes').mockImplementation(function (
this: string,
marker,
position
) {
if (meter.work && (marker === '\uFEFF' || marker === NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER)) {
meter.work.scans.push({ source: String(this), marker })
}
return includes.call(this, marker, position)
})
}
it.each([20, 200])(
'bounds actual parsing, clone, position and scan work at production updates N=%i',
(n) => {
installWorkMeters()
const fence = `\`\`\`text\n${'value\n'.repeat(n)}\`\`\`\n\n`
const tails = [
'Next',
...Array.from({ length: n }, (_, i) => `Next${'x'.repeat(i + 1)}`),
`Next${'x'.repeat(n)}\n\n${directive()}\n\nend`
]
const runs: ReturnType<typeof createParseWork>[][] = []
for (const legacy of [true, false]) {
meter.legacy = legacy
meter.work = null
const view = render(native('', true, true, 'document'))
const updates: ReturnType<typeof createParseWork>[] = []
for (const tail of tails) {
meter.work = createParseWork()
view.rerender(native(fence + tail, true, true, 'document'))
updates.push(meter.work)
}
view.unmount()
runs.push(updates)
}
const optimized = runs[1]
expect(optimized[0].discovery).toEqual([fence + tails[0]])
for (let i = 1; i <= n; i++) {
const work = optimized[i]
expect(work.discovery).toEqual([tails[i]])
expect(work.render).toEqual([])
expect(work.shifts).toBe(4)
expect(work.cloneNodes).toBe(3)
expect(work.cloneBytes).toBeLessThan(tails[i].length + 400)
expect(work.blocks).toBe(2)
expect(work.scans).toEqual([
{ source: fence + tails[i], marker: '\uFEFF' },
{ source: tails[i], marker: NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER }
])
}
const rejected = optimized.at(-1)!
expect(rejected.discovery).toEqual([fence + tails.at(-1)])
expect(rejected.render.some((source) => source.includes('value\n'))).toBe(false)
expect(rejected.render.reduce((n, s) => n + s.length, 0)).toBeLessThanOrEqual(
runs[0].at(-1)!.render.reduce((n, s) => n + s.length, 0)
)
expect(rejected.shifts).toBe(0)
expect(rejected.cloneNodes).toBe(0)
expect(rejected.blocks).toBe(4)
}
)
it.each([20, 200])('keeps visual-adjacent fallback parse work within main at N=%i', (n) => {
const runs: ReturnType<typeof createParseWork>[][] = []
const outputs: string[][] = []
for (const legacy of [true, false]) {
meter.legacy = legacy
meter.work = null
const view = render(native(''))
const updates: ReturnType<typeof createParseWork>[] = []
const html: string[] = []
for (let i = 0; i <= n; i++) {
meter.work = createParseWork()
view.rerender(native(`${directive()}\n\n${'word '.repeat(i)}end`))
updates.push(meter.work)
html.push(view.container.innerHTML)
}
view.unmount()
runs.push(updates)
outputs.push(html)
}
expect(outputs[1]).toEqual(outputs[0])
for (let i = 0; i <= n; i++) {
expect(runs[1][i].discovery).toEqual(runs[0][i].discovery)
expect(runs[1][i].render).toEqual(runs[0][i].render)
expect(runs[1][i].shifts).toBe(0)
}
})
it('leaves old visual/fence elements mounted when reuse resumes beyond an earlier marker', () => {
const full = `${directive()}\n\n${prefix}`
const view = render(native(full))
const visual = view.container.querySelector('[data-visual-file]')
const fence = view.container.querySelector('pre')
view.rerender(native(`${full} words`))
meter.work = createParseWork()
view.rerender(native(`${full} words again`))
expect(meter.work.discovery).toEqual(['Next words again'])
view.rerender(native(`${full} words again\n\n${directive(1)}\n\nend`))
expect(view.container.querySelector('[data-visual-file]')).toBe(visual)
expect(view.container.querySelector('pre')).toBe(fence)
})
it('invalidates raw grammar metadata when an extension nonce changes on the same source', () => {
const makeExtension = (nonce: string): CommentMarkdownExtension => ({
remarkPlugins: [[remarkNativeChatVisuals, nonce]],
extraFlowSyntaxMarkers: [NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER],
sanitizeAttributes: {},
components: {}
})
const snapshots: string[][] = []
for (const legacy of [true, false]) {
meter.legacy = legacy
const first = makeExtension('first'),
second = makeExtension('second')
const view = render(
<CommentMarkdown content={prefix} growing extension={first} renderCodeBlock={code} />
)
view.rerender(
<CommentMarkdown content={prefix} growing extension={second} renderCodeBlock={code} />
)
const html = [view.container.innerHTML]
view.rerender(
<CommentMarkdown
content={`${prefix}\n\n${directive()}\n\nend`}
growing
extension={second}
renderCodeBlock={code}
/>
)
html.push(view.container.innerHTML)
snapshots.push(html)
view.unmount()
}
expect(snapshots[1]).toEqual(snapshots[0])
})
it.each([20, 200])(
'excludes the settled prefix from syntax/clone work with a bounded live tail N=%i',
(n) => {
installWorkMeters()
const fence = `\`\`\`text\n${'value\n'.repeat(n)}\`\`\`\n\n`
let source = `${fence}word`
const view = render(native(source, true, true, 'document'))
for (let i = 1; i <= n; i++) {
source += '\n\nword'
meter.work = createParseWork()
view.rerender(native(source, true, true, 'document'))
expect(meter.work.discovery).toHaveLength(1)
expect(meter.work.discovery[0].length).toBeLessThanOrEqual(16)
expect(meter.work.render).toEqual([])
expect(meter.work.shifts).toBeLessThanOrEqual(8)
expect(meter.work.cloneNodes).toBeLessThanOrEqual(6)
expect(meter.work.cloneBytes).toBeLessThanOrEqual(800)
expect(meter.work.scans).toEqual([
{ source, marker: '\uFEFF' },
{ source: meter.work.discovery[0], marker: NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER }
])
expect(meter.work.blocks).toBe(i + 2)
}
}
)
@@ -3,11 +3,14 @@ import {
CommentMarkdownMermaidContext,
CommentMarkdownMermaidSourceContext
} from './comment-markdown-mermaid-policy'
import Markdown, { defaultUrlTransform, type Components } from 'react-markdown'
import {
defaultUrlTransform,
type Components,
type Options as MarkdownOptions
} from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { remarkChatMath } from './chat-markdown-math'
import { useChatMathRendering } from './chat-markdown-math-rendering'
import { copyChatMathSelection } from './chat-markdown-math-selection'
import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly'
import rehypeRaw from 'rehype-raw'
@@ -25,14 +28,13 @@ import {
type FileLinkExists
} from './comment-markdown-native-chat-file-links'
import { CommentMarkdownBlockLineContext } from './comment-markdown-code-renderers'
import { rehypeWordFade } from './comment-markdown-word-fade'
import { remarkUnknownHtmlAsText } from './comment-markdown-unknown-html-as-text'
import { CommentMarkdownWords } from './CommentMarkdownWords'
import {
splitMarkdownTopLevelBlocks,
type MarkdownBlock,
type MarkdownBlockSplit
} from './markdown-top-level-blocks'
MemoizedMarkdown,
useMarkdownBlocks,
markdownBlockContent
} from './comment-markdown-block-pipeline'
import {
GITHUB_CALLOUT_SANITIZE_ATTRIBUTE,
remarkGitHubCallouts
@@ -41,9 +43,9 @@ import { remarkAutolinkLiteralProseBoundary } from '@/lib/remark-autolink-litera
export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers'
type MarkdownPlugins = NonNullable<React.ComponentProps<typeof Markdown>['rehypePlugins']>
type RemarkPlugins = NonNullable<React.ComponentProps<typeof Markdown>['remarkPlugins']>
type UrlTransform = NonNullable<React.ComponentProps<typeof Markdown>['urlTransform']>
type MarkdownPlugins = NonNullable<MarkdownOptions['rehypePlugins']>
type RemarkPlugins = NonNullable<MarkdownOptions['remarkPlugins']>
type UrlTransform = NonNullable<MarkdownOptions['urlTransform']>
type GitHubRepoReference = {
owner: string
@@ -232,6 +234,8 @@ const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkd
/** Keep extensions stable per surface; new objects rebuild components and remount their contents. */
export type CommentMarkdownExtension = {
remarkPlugins: RemarkPlugins
/** Extra flow syntax requires one of these markers; omitted means unknown grammar. */
extraFlowSyntaxMarkers?: readonly string[]
sanitizeAttributes: Record<string, readonly string[]>
components: Components
}
@@ -270,49 +274,6 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
unknownHtmlAsText?: boolean
}
/** One render of the pipeline; memoized so an unchanged block is not parsed again. */
const MemoizedMarkdown = React.memo(function DeferredMarkdown({
math,
fadeWords,
...props
}: React.ComponentProps<typeof Markdown> & { math: boolean; fadeWords: boolean }) {
const renderMath = useChatMathRendering(math)
const plugins = React.useMemo(
() => [
...(props.rehypePlugins ?? []),
...(math ? [renderMath] : []),
...(fadeWords ? [rehypeWordFade] : [])
],
[props.rehypePlugins, math, renderMath, fadeWords]
)
return <Markdown {...props} rehypePlugins={plugins} />
})
/** The content as the blocks to render. Cut into blocks from the first time it is growing, and
* from then on, so the stream ending does not redraw what is already there. */
function useMarkdownBlocks(
content: string,
growing: boolean,
math: boolean
): readonly MarkdownBlock[] {
const [previous, setPrevious] = React.useState<MarkdownBlockSplit | null>(null)
if (!growing && previous === null) {
return [{ start: 0, line: 1, text: content }]
}
if (previous?.source === content) {
return previous.blocks
}
const next = splitMarkdownTopLevelBlocks(content, previous, math)
setPrevious(next)
return next.blocks
}
function markdownBlockContent(block: MarkdownBlock): string {
return block.literalMath && block.end !== undefined
? block.text.slice(0, block.end - block.start)
: block.text
}
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
// and event handlers (onPointerEnter, onPointerLeave, data-state, etc.) onto
// the child. Without forwarding both, the hover card cannot open or position.
@@ -371,7 +332,7 @@ const CommentMarkdown = React.memo(
const withExtension = extension ? [...withMath, ...extension.remarkPlugins] : withMath
return githubRepo ? [...withExtension, remarkGitHubReferences(githubRepo)] : withExtension
}, [extension, fileLinkExists, githubRepo, math, unknownHtmlAsText])
const blocks = useMarkdownBlocks(content, growing, math)
const blocks = useMarkdownBlocks(content, growing, math, activeRemarkPlugins, extension)
const [hadWordFade, setHadWordFade] = React.useState(fadeWords)
if (fadeWords && !hadWordFade) {
setHadWordFade(true)
@@ -400,6 +361,7 @@ const CommentMarkdown = React.memo(
{blocks.map((block, index) => (
<CommentMarkdownBlockLineContext.Provider key={block.start} value={block.line}>
<MemoizedMarkdown
block={block.tree ? block : undefined}
remarkPlugins={activeRemarkPlugins}
rehypePlugins={activeRehypePlugins}
math={math}
@@ -0,0 +1,103 @@
import React from 'react'
import Markdown from 'react-markdown'
import { unified, type Processor } from 'unified'
import remarkParse from 'remark-parse'
import type { CommentMarkdownExtension } from './CommentMarkdown'
import { useChatMathRendering } from './chat-markdown-math-rendering'
import { rehypeWordFade } from './comment-markdown-word-fade'
import {
splitMarkdownTopLevelBlocks,
type MarkdownBlock,
type MarkdownBlockSplit
} from './markdown-top-level-blocks'
type RemarkPlugins = NonNullable<React.ComponentProps<typeof Markdown>['remarkPlugins']>
/** One render of the pipeline; memoized so an unchanged block is not parsed again. */
export const MemoizedMarkdown = React.memo(function DeferredMarkdown({
math,
fadeWords,
block,
...props
}: React.ComponentProps<typeof Markdown> & {
math: boolean
fadeWords: boolean
block?: MarkdownBlock
}) {
const renderMath = useChatMathRendering(math)
const plugins = React.useMemo(
() => [
...(props.rehypePlugins ?? []),
...(math ? [renderMath] : []),
...(fadeWords ? [rehypeWordFade] : [])
],
[props.rehypePlugins, math, renderMath, fadeWords]
)
const parsedPlugins = React.useMemo<RemarkPlugins>(() => {
if (!block?.tree) {
return props.remarkPlugins ?? []
}
const tree = block.tree
const source = markdownBlockContent(block)
// Transforms mutate mdast; each render owns a fresh copy of the pristine parse.
function reuseParsedBlock(this: Processor): void {
const parse = this.parser
if (!parse) {
return
}
this.parser = (value, file) => (value === source ? structuredClone(tree) : parse(value, file))
}
return [...(props.remarkPlugins ?? []), reuseParsedBlock]
}, [block, props.remarkPlugins])
return <Markdown {...props} remarkPlugins={parsedPlugins} rehypePlugins={plugins} />
})
/** The content as the blocks to render. Cut into blocks from the first time it is growing, and
* from then on, so the stream ending does not redraw what is already there. */
export function useMarkdownBlocks(
content: string,
growing: boolean,
math: boolean,
plugins: RemarkPlugins,
extension?: CommentMarkdownExtension
): readonly MarkdownBlock[] {
const parser = React.useMemo(() => unified().use(remarkParse).use(plugins), [plugins])
const [state, setState] = React.useState<{
parser: typeof parser
split: MarkdownBlockSplit
} | null>(null)
if (state && state.parser !== parser) {
const split = {
...state.split,
blocks: state.split.blocks.map(
({ tree: _tree, closedFence: _closedFence, ...block }) => block
)
}
setState({ parser, split })
return split.blocks
}
const previous = state?.split ?? null
if (!growing && state === null) {
return [{ start: 0, line: 1, text: content }]
}
if (previous?.source === content) {
return previous.blocks
}
const markers = extension?.extraFlowSyntaxMarkers
const knownGrammar = !extension?.remarkPlugins.length || markers !== undefined
const next = splitMarkdownTopLevelBlocks(
content,
previous,
math,
knownGrammar ? (source) => parser.parse(source) : undefined,
(tail) => !content.includes('\uFEFF') && !markers?.some((marker) => tail.includes(marker))
)
setState({ parser, split: next })
return next.blocks
}
export function markdownBlockContent(block: MarkdownBlock): string {
return block.literalMath && block.end !== undefined
? block.text.slice(0, block.end - block.start)
: block.text
}
@@ -0,0 +1,51 @@
import type { Nodes, Root } from 'mdast'
type ParseWork = {
discovery: string[]
render: string[]
shifts: number
cloneNodes: number
cloneBytes: number
scans: { source: string; marker: string }[]
blocks: number
}
export function createParseWork(): ParseWork {
return {
discovery: [],
render: [],
shifts: 0,
cloneNodes: 0,
cloneBytes: 0,
scans: [],
blocks: 0
}
}
export function nodeCount(node: Nodes): number {
return 1 + ('children' in node ? node.children.reduce((n, child) => n + nodeCount(child), 0) : 0)
}
// Meter actual coordinate writes, leaving the parser's values and clone semantics unchanged.
export function meterPositions(tree: Root, onWrite: () => void): void {
function visit(node: Nodes): void {
if (node.position) {
for (const point of [node.position.start, node.position.end]) {
let offset = point.offset
Object.defineProperty(point, 'offset', {
enumerable: true,
configurable: true,
get: () => offset,
set: (value: number) => {
onWrite()
offset = value
}
})
}
}
if ('children' in node) {
node.children.forEach(visit)
}
}
visit(tree)
}
@@ -0,0 +1,103 @@
// Frozen splitter: differential tests compare every streaming prefix with the previous renderer.
// Reparse the last two blocks on append; cross-block definitions or HTML fall back to the whole document.
import type { Nodes } from 'mdast'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { remarkChatMath } from './chat-markdown-math'
import { chatMathCodeFences } from './chat-markdown-math-nodes'
import { unified } from 'unified'
export type MarkdownBlock = {
/** Source offset, retained for settled blocks on append. */
start: number
/** One-based source line. */
line: number
text: string
literalMath?: boolean
/** Parser-owned end, excluding separators held for the next block. */
end?: number
}
export type MarkdownBlockSplit = {
source: string
blocks: readonly MarkdownBlock[]
/** Cross-block content requires whole-document rendering. */
whole: boolean
}
/** The last block, which is still growing, and the one it may yet join. */
const UNSETTLED_BLOCKS = 2
const blockParser = unified().use(remarkParse).use(remarkGfm)
const mathBlockParser = blockParser().use(remarkChatMath)
/** Tags that cannot hold anything, so cannot hold a later block. */
const VOID_TAG_PATTERN = /^<(?:br|hr|wbr|img)\b[^>]*>$/i
/** References and non-void HTML can affect other blocks and require whole-document parsing. */
function reachesAcrossBlocks(node: Nodes): boolean {
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
return true
}
if (node.type === 'html') {
return !VOID_TAG_PATTERN.test(node.value.trim())
}
return 'children' in node && node.children.some(reachesAcrossBlocks)
}
function hasLiteralMath(node: Nodes | undefined): boolean {
if (!node) {
return false
}
return (
node.data?.mathComplete === false ||
node.data?.mathLiteral === true ||
('children' in node && node.children.some(hasLiteralMath))
)
}
function wholeDocument(source: string, literalMath = false): MarkdownBlockSplit {
return { source, blocks: [{ start: 0, line: 1, text: source, literalMath }], whole: true }
}
export function splitMarkdownTopLevelBlocks(
source: string,
previous: MarkdownBlockSplit | null,
math = false
): MarkdownBlockSplit {
const grewFromPrevious = previous !== null && source.startsWith(previous.source)
if (grewFromPrevious && previous.whole && !math) {
return wholeDocument(source)
}
const settled = grewFromPrevious ? previous.blocks.slice(0, -UNSETTLED_BLOCKS) : []
const reparsed = grewFromPrevious ? previous.blocks.at(-UNSETTLED_BLOCKS) : undefined
const base = reparsed?.start ?? 0
const baseLine = reparsed?.line ?? 1
const tail = source.slice(base)
const tree = (math ? mathBlockParser : blockParser).parse(tail)
if (math) {
chatMathCodeFences(tree, tail)
}
if (reachesAcrossBlocks(tree)) {
return wholeDocument(source, math && hasLiteralMath(tree))
}
// Each block runs to the next one's start, so the blank lines between stay in the source.
const points = tree.children.map((child) =>
'position' in child ? child.position?.start : undefined
)
const starts = points.map((point) => point?.offset ?? 0)
starts[0] = 0
const blocks = starts.map((start, index) => ({
start: base + start,
line: baseLine + (index === 0 ? 0 : (points[index]?.line ?? 1) - 1),
text: tail.slice(start, starts[index + 1]),
...(math
? {
literalMath: hasLiteralMath(tree.children[index]),
end: base + (tree.children[index]?.position?.end.offset ?? tail.length)
}
: {})
}))
return { source, blocks: [...settled, ...blocks], whole: false }
}
@@ -1,6 +1,6 @@
// Reparse the last two blocks on append; cross-block definitions or HTML fall back to the whole document.
// Reparse unsettled blocks; cross-block definitions or HTML require the whole document.
import type { Nodes } from 'mdast'
import type { Nodes, Root } from 'mdast'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { remarkChatMath } from './chat-markdown-math'
@@ -16,6 +16,9 @@ export type MarkdownBlock = {
literalMath?: boolean
/** Parser-owned end, excluding separators held for the next block. */
end?: number
/** Pristine tree in block-local coordinates, before rendering transforms. */
tree?: Root
closedFence?: boolean
}
export type MarkdownBlockSplit = {
@@ -63,23 +66,43 @@ function wholeDocument(source: string, literalMath = false): MarkdownBlockSplit
export function splitMarkdownTopLevelBlocks(
source: string,
previous: MarkdownBlockSplit | null,
math = false
math = false,
parseTree?: (source: string) => Root,
canReuse: (tail: string) => boolean = () => !source.includes('\uFEFF')
): MarkdownBlockSplit {
let parse = parseTree
const grewFromPrevious = previous !== null && source.startsWith(previous.source)
if (grewFromPrevious && previous.whole && !math) {
return wholeDocument(source)
}
const settled = grewFromPrevious ? previous.blocks.slice(0, -UNSETTLED_BLOCKS) : []
const reparsed = grewFromPrevious ? previous.blocks.at(-UNSETTLED_BLOCKS) : undefined
// A sliced, indented opener must retain the original discovery context.
const last = grewFromPrevious ? previous.blocks.at(-1) : undefined
let unsettled =
parse &&
grewFromPrevious &&
previous.blocks.at(-UNSETTLED_BLOCKS)?.closedFence &&
last &&
source[last.start - 1] === '\n'
? 1
: UNSETTLED_BLOCKS
// Declining must restore the original discovery window before choosing any cuts.
const candidateBase = grewFromPrevious ? (previous.blocks.at(-unsettled)?.start ?? 0) : 0
if (parse && !canReuse(source.slice(candidateBase))) {
parse = undefined
unsettled = UNSETTLED_BLOCKS
}
const settled = grewFromPrevious ? previous.blocks.slice(0, -unsettled) : []
const reparsed = grewFromPrevious ? previous.blocks.at(-unsettled) : undefined
const base = reparsed?.start ?? 0
const baseLine = reparsed?.line ?? 1
const tail = source.slice(base)
const tree = (math ? mathBlockParser : blockParser).parse(tail)
const tree = parse ? parse(tail) : (math ? mathBlockParser : blockParser).parse(tail)
if (math) {
chatMathCodeFences(tree, tail)
}
if (reachesAcrossBlocks(tree)) {
return wholeDocument(source, math && hasLiteralMath(tree))
const whole = wholeDocument(source, math && hasLiteralMath(tree))
return parse && base === 0 ? { ...whole, blocks: [{ ...whole.blocks[0], tree }] } : whole
}
// Each block runs to the next one's start, so the blank lines between stay in the source.
const points = tree.children.map((child) =>
@@ -87,16 +110,68 @@ export function splitMarkdownTopLevelBlocks(
)
const starts = points.map((point) => point?.offset ?? 0)
starts[0] = 0
const blocks = starts.map((start, index) => ({
start: base + start,
line: baseLine + (index === 0 ? 0 : (points[index]?.line ?? 1) - 1),
text: tail.slice(start, starts[index + 1]),
...(math
? {
literalMath: hasLiteralMath(tree.children[index]),
end: base + (tree.children[index]?.position?.end.offset ?? tail.length)
}
: {})
}))
const blocks = starts.map((start, index): MarkdownBlock => {
const child = tree.children[index]
const text = tail.slice(start, starts[index + 1])
const line = index === 0 ? 1 : (points[index]?.line ?? 1)
const block: MarkdownBlock = {
start: base + start,
line: baseLine + line - 1,
text,
...(math
? {
literalMath: hasLiteralMath(child),
end: base + (child?.position?.end.offset ?? tail.length)
}
: {})
}
const prior = grewFromPrevious ? previous.blocks[settled.length + index] : undefined
if (
prior?.tree &&
prior.start === block.start &&
prior.text === text &&
prior.literalMath === block.literalMath &&
prior.end === block.end
) {
return prior
}
if (parse && child) {
block.closedFence = isClosedFence(child, text, start)
// Slicing an indented opener changes its standalone parse, so preserve that parse.
if (index === 0 || points[index]?.column === 1) {
block.tree = { type: 'root', children: [child] }
shiftMarkdownPositions(block.tree, start, line - 1)
}
}
return block
})
return { source, blocks: [...settled, ...blocks], whole: false }
}
function shiftMarkdownPositions(node: Nodes, offset: number, lines: number): void {
if (node.position) {
for (const point of [node.position.start, node.position.end]) {
if (point.offset !== undefined) {
point.offset -= offset
}
point.line -= lines
}
}
if ('children' in node) {
node.children.forEach((child) => shiftMarkdownPositions(child, offset, lines))
}
}
function isClosedFence(node: Nodes, source: string, offset: number): boolean {
if (node.type !== 'code') {
return false
}
const opening = /^ {0,3}(`{3,}|~{3,})[^\r\n]*\r?\n/.exec(source)?.[1]
if (!opening || !node.position) {
return false
}
const end = (node.position.end.offset ?? offset + source.length) - offset
const authored = source.slice(0, end)
const lastLine = authored.slice(authored.lastIndexOf('\n') + 1).replace(/\r$/, '')
return new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \t]*$`).test(lastLine)
}