markdown-incremental.spec.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. // @vitest-environment jsdom
  2. // Incremental streaming behavior: a MarkdownText kept mounted across
  3. // append-only rerenders must show, at every step, exactly the DOM a fresh
  4. // mount of the same prefix shows, while reusing the frozen blocks' DOM nodes
  5. // instead of remounting them.
  6. import { cleanup, render } from '@testing-library/react'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. import type { Root, RootContent } from 'mdast'
  9. import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
  10. import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
  11. import { parseGfm } from '../src/markdown/parse.ts'
  12. afterEach(cleanup)
  13. /**
  14. * A many-block document exercising every freeze-sensitive construct. The
  15. * prefix-equivalence property below holds only while no reference or
  16. * footnote definition lands on the far side of a freeze boundary from its
  17. * use: a fresh mount parses everything in one tree while the live stream's
  18. * frozen blocks are already baked (the fingerprint test demonstrates the
  19. * documented deviation). Keep definitions adjacent to their references when
  20. * extending this corpus.
  21. */
  22. const STREAM_DOC = [
  23. '# Title',
  24. '',
  25. 'First paragraph with **strong** and `code`.',
  26. '',
  27. '- list item one',
  28. '- list item two',
  29. '',
  30. ' continuation of item two',
  31. '',
  32. 'Setext heading',
  33. '===',
  34. '',
  35. '| a | b |',
  36. '| --- | --- |',
  37. '| 1 | 2 |',
  38. '',
  39. '```ts',
  40. 'const x = 1',
  41. '',
  42. 'still inside the fence',
  43. '```',
  44. '',
  45. '> quote with lazy',
  46. 'continuation line',
  47. '',
  48. 'Uses a footnote[^n] twice[^n].',
  49. '',
  50. '[^n]: The footnote body.',
  51. '',
  52. 'Closing paragraph after enough blocks to freeze everything above.',
  53. '',
  54. 'One more tail block.',
  55. ].join('\n')
  56. describe('incremental streaming rendering', () => {
  57. for (const chunkSize of [1, 3, 7, 16]) {
  58. it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => {
  59. const live = render(<MarkdownText text="" streaming />)
  60. for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) {
  61. const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))
  62. live.rerender(<MarkdownText text={prefix} streaming />)
  63. const fresh = render(<MarkdownText text={prefix} streaming />)
  64. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  65. fresh.unmount()
  66. }
  67. live.unmount()
  68. })
  69. }
  70. it('keeps frozen block DOM nodes across freezes instead of remounting', () => {
  71. const paragraphs = Array.from({ length: 8 }, (_, i) => `Paragraph number ${i}.`)
  72. const first = `${paragraphs[0]}\n\n`
  73. const live = render(<MarkdownText text={first} streaming />)
  74. const firstBlock = live.container.querySelector('p')
  75. expect(firstBlock?.textContent).toBe(paragraphs[0])
  76. live.rerender(<MarkdownText text={paragraphs.join('\n\n')} streaming />)
  77. // Same DOM node instance: the block kept its key across the freeze boundary.
  78. expect(live.container.querySelector('p')).toBe(firstBlock)
  79. expect(live.container.querySelectorAll('p')).toHaveLength(paragraphs.length)
  80. live.unmount()
  81. })
  82. it('recovers when the text diverges instead of appending', () => {
  83. const live = render(<MarkdownText text={'alpha\n\nbeta\n\ngamma\n\ndelta'} streaming />)
  84. live.rerender(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
  85. const fresh = render(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
  86. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  87. live.unmount()
  88. fresh.unmount()
  89. })
  90. it('drops the streaming cache when the copy labels change identity', () => {
  91. const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
  92. const live = render(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy' }} />)
  93. expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
  94. live.rerender(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren' }} />)
  95. expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
  96. live.unmount()
  97. })
  98. it('settles into the full math-enabled render after streaming', () => {
  99. const doc = 'Value $E = mc^2$ inline.\n\nSecond.\n\nThird.\n\nFourth.'
  100. const live = render(<MarkdownText text={doc} streaming />)
  101. expect(live.container.querySelector('.katex')).toBeNull()
  102. live.rerender(<MarkdownText text={doc} />)
  103. const settled = render(<MarkdownText text={doc} />)
  104. expect(live.container.innerHTML).toBe(settled.container.innerHTML)
  105. expect(live.container.querySelector('.katex')).not.toBeNull()
  106. live.unmount()
  107. settled.unmount()
  108. })
  109. })
  110. describe('incremental parsing is actually in effect', () => {
  111. it('hands the grammar only the source tail once blocks freeze', () => {
  112. const calls: string[] = []
  113. const recording = (text: string): Root => {
  114. calls.push(text)
  115. return parseGfm(text)
  116. }
  117. const parser = new IncrementalMarkdownParser(recording)
  118. const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`)
  119. let text = ''
  120. for (const paragraph of paragraphs) {
  121. text += `${paragraph}\n\n`
  122. parser.update(text)
  123. }
  124. expect(text.length).toBeGreaterThan(1500)
  125. // Warm-up aside, every parse sees only the unstable tail: bounded by a
  126. // few paragraphs, not the growing document.
  127. const steady = calls.slice(5)
  128. expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200)
  129. expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true)
  130. // Cumulative parsed bytes stay linear in the document; full re-parsing
  131. // would have accumulated ~40/2 times the document length here.
  132. const totalParsed = calls.reduce((sum, call) => sum + call.length, 0)
  133. expect(totalParsed).toBeLessThan(text.length * 5)
  134. })
  135. it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => {
  136. const doc = [
  137. '[ref]: https://example.com/target',
  138. '',
  139. 'Paragraph one keeps the definition company.',
  140. '',
  141. 'Paragraph two pushes the freeze boundary.',
  142. '',
  143. 'Paragraph three freezes the definition out.',
  144. '',
  145. 'See [the link][ref] for details.',
  146. ].join('\n')
  147. const head = doc.slice(0, doc.indexOf('See'))
  148. const live = render(<MarkdownText text={head} streaming />)
  149. live.rerender(<MarkdownText text={doc} streaming />)
  150. // The tail re-parse cannot see the frozen definition, so the reference
  151. // stays literal — the direct observable that the whole text was NOT
  152. // re-parsed (a one-shot mount of the same text resolves it).
  153. expect(live.container.querySelector('a')).toBeNull()
  154. expect(live.container.textContent).toContain('[the link][ref]')
  155. const fresh = render(<MarkdownText text={doc} streaming />)
  156. expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
  157. fresh.unmount()
  158. // The settled swap re-parses everything and heals the deviation.
  159. live.rerender(<MarkdownText text={doc} />)
  160. expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
  161. live.unmount()
  162. })
  163. })
  164. describe('freeze dynamics around frontier-sensitive constructs', () => {
  165. it('an unclosed fence pins the tail: nothing freezes until it closes', () => {
  166. const parser = new IncrementalMarkdownParser(parseGfm)
  167. let text = 'p1.\n\np2.\n\np3.\n\n```ts\n'
  168. const opened = parser.update(text)
  169. const frozenAtOpen = opened.frozen.length
  170. expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code')
  171. for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) {
  172. text += line
  173. const grown = parser.update(text)
  174. // The fence swallows everything appended, so the block census cannot
  175. // grow and the freeze boundary must hold still.
  176. expect(grown.frozen.length).toBe(frozenAtOpen)
  177. expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code')
  178. }
  179. text += '```\n\nafter one.\n\nafter two.\n'
  180. const closed = parser.update(text)
  181. expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen)
  182. const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node
  183. expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list')
  184. })
  185. it('a list can keep extending across blank lines until it freezes whole', () => {
  186. const parser = new IncrementalMarkdownParser(parseGfm)
  187. let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n'
  188. const before = parser.update(text)
  189. const frozenBefore = before.frozen.length
  190. text += '\n- item c\n'
  191. const extended = parser.update(text)
  192. expect(extended.frozen.length).toBe(frozenBefore)
  193. const tailList = extended.tail[extended.tail.length - 1]?.node
  194. expect(tailList?.type === 'list' && tailList.children).toHaveLength(3)
  195. text += '\nafter.\n\nmore.\n\nend.\n'
  196. const after = parser.update(text)
  197. const frozenList = after.frozen.find(block => block.node.type === 'list')?.node
  198. expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3)
  199. })
  200. it('keeps every previously frozen key as a stable prefix across the stream', () => {
  201. const parser = new IncrementalMarkdownParser(parseGfm)
  202. let previous: readonly number[] = []
  203. for (let end = 7; end < STREAM_DOC.length + 7; end += 7) {
  204. const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)))
  205. const keys = frozen.map(block => block.key)
  206. expect(keys.slice(0, previous.length)).toEqual(previous)
  207. previous = keys
  208. }
  209. expect(previous.length).toBeGreaterThan(4)
  210. })
  211. })
  212. describe('multibyte content', () => {
  213. const CJK_DOC = [
  214. '# 标题 🎉',
  215. '',
  216. '这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。',
  217. '',
  218. '- 列表项一 ✅',
  219. '- 列表项二',
  220. '',
  221. '> 引用一行,带表情 🐟',
  222. '',
  223. '```',
  224. '中文代码 🎯',
  225. '```',
  226. '',
  227. '| 键 | 值 |',
  228. '| --- | --- |',
  229. '| 甲 | 乙 |',
  230. '',
  231. '结尾段落,足够多的块让前面全部冻结。🌊',
  232. ].join('\n')
  233. it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => {
  234. const live = render(<MarkdownText text="" streaming />)
  235. for (let end = 1; end < CJK_DOC.length + 1; end += 1) {
  236. const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))
  237. live.rerender(<MarkdownText text={prefix} streaming />)
  238. const fresh = render(<MarkdownText text={prefix} streaming />)
  239. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  240. fresh.unmount()
  241. }
  242. live.unmount()
  243. })
  244. it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => {
  245. const parser = new IncrementalMarkdownParser(parseGfm)
  246. let result = parser.update(CJK_DOC.slice(0, 3))
  247. for (let end = 6; end < CJK_DOC.length + 3; end += 3) {
  248. result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)))
  249. }
  250. const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset)
  251. expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot)
  252. expect(result.frozen.length).toBeGreaterThan(3)
  253. })
  254. })
  255. describe('streaming composition across freezes', () => {
  256. it('continues footnote numbering from frozen references and lists all definitions', () => {
  257. const doc = [
  258. 'Alpha uses a footnote[^a].',
  259. '',
  260. '[^a]: First note body.',
  261. '',
  262. 'Filler one.',
  263. '',
  264. 'Filler two.',
  265. '',
  266. 'Filler three.',
  267. '',
  268. 'Beta uses another[^b].',
  269. '',
  270. '[^b]: Second note body.',
  271. ].join('\n')
  272. const head = doc.slice(0, doc.indexOf('Beta'))
  273. const live = render(<MarkdownText text={head} streaming />)
  274. live.rerender(<MarkdownText text={doc} streaming />)
  275. expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2'])
  276. expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id))
  277. .toEqual(['user-content-fn-a', 'user-content-fn-b'])
  278. expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩')
  279. const fresh = render(<MarkdownText text={doc} streaming />)
  280. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  281. fresh.unmount()
  282. live.unmount()
  283. })
  284. it('keeps every frozen block DOM node through the rest of the stream', () => {
  285. const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`)
  286. const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n`
  287. const live = render(<MarkdownText text={half} streaming />)
  288. const captured = [...live.container.querySelectorAll('p')]
  289. expect(captured.length).toBe(6)
  290. let text = half
  291. for (const paragraph of paragraphs.slice(6)) {
  292. text += `${paragraph}\n\n`
  293. live.rerender(<MarkdownText text={text} streaming />)
  294. }
  295. const finalNodes = [...live.container.querySelectorAll('p')]
  296. expect(finalNodes.slice(0, 6)).toEqual(captured)
  297. expect(finalNodes).toHaveLength(12)
  298. live.unmount()
  299. })
  300. it('renders an empty document for definition-only streams, including trailing blank lines', () => {
  301. const doc = '[a]: https://example.com/1\n\n[b]: https://example.com/2\n\n[c]: https://example.com/3\n\n[d]: https://example.com/4'
  302. const live = render(<MarkdownText text={doc.slice(0, 30)} streaming />)
  303. live.rerender(<MarkdownText text={doc} streaming />)
  304. live.rerender(<MarkdownText text={`${doc}\n\n\n`} streaming />)
  305. const fresh = render(<MarkdownText text={`${doc}\n\n\n`} streaming />)
  306. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  307. expect(live.container.querySelector('div')?.childNodes).toHaveLength(0)
  308. fresh.unmount()
  309. live.unmount()
  310. })
  311. it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => {
  312. const live = render(<MarkdownText text={'a.\n\nb.'} streaming />)
  313. live.rerender(<MarkdownText text={'a.\n\nb.'} />)
  314. const settled = render(<MarkdownText text={'a.\n\nb.'} />)
  315. expect(live.container.innerHTML).toBe(settled.container.innerHTML)
  316. settled.unmount()
  317. live.rerender(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
  318. const fresh = render(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
  319. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  320. fresh.unmount()
  321. live.unmount()
  322. })
  323. it('matches fresh renders under irregular deterministic chunk sizes', () => {
  324. let seed = 42
  325. const nextSize = (): number => {
  326. seed = (seed * 1103515245 + 12345) % 2147483648
  327. return 1 + (seed % 13)
  328. }
  329. const live = render(<MarkdownText text="" streaming />)
  330. let end = 0
  331. while (end < STREAM_DOC.length) {
  332. end = Math.min(end + nextSize(), STREAM_DOC.length)
  333. const prefix = STREAM_DOC.slice(0, end)
  334. live.rerender(<MarkdownText text={prefix} streaming />)
  335. const fresh = render(<MarkdownText text={prefix} streaming />)
  336. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  337. fresh.unmount()
  338. }
  339. live.unmount()
  340. })
  341. })
  342. describe('IncrementalMarkdownParser', () => {
  343. it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => {
  344. const parser = new IncrementalMarkdownParser(parseGfm)
  345. const first = parser.update('a\n\nb\n\nc\n\nd\n\ne')
  346. expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph'])
  347. expect(first.tail).toHaveLength(2)
  348. const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng')
  349. expect(second.frozen).toHaveLength(5)
  350. expect(second.tail).toHaveLength(2)
  351. // Previously returned frozen entries keep their identity and keys.
  352. expect(second.frozen.slice(0, 3)).toEqual(first.frozen)
  353. expect(second.generation).toBe(first.generation)
  354. })
  355. it('holds every block in the tail until more than two exist', () => {
  356. const parser = new IncrementalMarkdownParser(parseGfm)
  357. const result = parser.update('only\n\ntwo blocks')
  358. expect(result.frozen).toHaveLength(0)
  359. expect(result.tail).toHaveLength(2)
  360. })
  361. it('returns the cached result for identical input', () => {
  362. const parser = new IncrementalMarkdownParser(parseGfm)
  363. const first = parser.update('a\n\nb\n\nc')
  364. expect(parser.update('a\n\nb\n\nc')).toBe(first)
  365. })
  366. it('bumps the generation and discards frozen blocks on non-append input', () => {
  367. const parser = new IncrementalMarkdownParser(parseGfm)
  368. const before = parser.update('a\n\nb\n\nc\n\nd')
  369. expect(before.frozen.length).toBeGreaterThan(0)
  370. const after = parser.update('different')
  371. expect(after.generation).toBe(before.generation + 1)
  372. expect(after.frozen).toHaveLength(0)
  373. expect(after.tail.map(b => b.node.type)).toEqual(['paragraph'])
  374. })
  375. it('keys blocks by absolute source offset across freezes', () => {
  376. const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee'
  377. const parser = new IncrementalMarkdownParser(parseGfm)
  378. const grown = parser.update(doc)
  379. const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset)
  380. expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys)
  381. })
  382. it('never freezes under a grammar that omits positions', () => {
  383. const bare = (text: string): Root => {
  384. const root = parseGfm(text)
  385. const strip = (nodes: RootContent[]): void => {
  386. for (const node of nodes) {
  387. delete node.position
  388. if ('children' in node) strip(node.children)
  389. }
  390. }
  391. strip(root.children)
  392. return root
  393. }
  394. const parser = new IncrementalMarkdownParser(bare)
  395. const result = parser.update('a\n\nb\n\nc\n\nd\n\ne')
  396. expect(result.frozen).toHaveLength(0)
  397. expect(result.tail).toHaveLength(5)
  398. // Fallback keys stay unique per sibling.
  399. expect(new Set(result.tail.map(b => b.key)).size).toBe(5)
  400. })
  401. })