markdown-incremental.client.spec.tsx 18 KB

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