1
0

markdown-incremental.client.spec.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  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. it('keeps a highlighted fence mounted across the final full-document parse', () => {
  114. const doc = 'before.\n\n```ts\nconst answer = 42\n```\n\nafter.'
  115. const live = render(<MarkdownText text={doc} streaming />)
  116. const line = live.container.querySelector('pre.shiki .line')
  117. expect(line).not.toBeNull()
  118. live.rerender(<MarkdownText text={doc} />)
  119. expect(live.container.querySelector('pre.shiki .line')).toBe(line)
  120. live.unmount()
  121. })
  122. })
  123. describe('incremental parsing is actually in effect', () => {
  124. it('hands the grammar only the source tail once blocks freeze', () => {
  125. const calls: string[] = []
  126. const recording = (text: string): Root => {
  127. calls.push(text)
  128. return parseGfm(text)
  129. }
  130. const parser = new IncrementalMarkdownParser(recording)
  131. const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`)
  132. let text = ''
  133. for (const paragraph of paragraphs) {
  134. text += `${paragraph}\n\n`
  135. parser.update(text)
  136. }
  137. expect(text.length).toBeGreaterThan(1500)
  138. // Warm-up aside, every parse sees only the unstable tail: bounded by a
  139. // few paragraphs, not the growing document.
  140. const steady = calls.slice(5)
  141. expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200)
  142. expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true)
  143. // Cumulative parsed bytes stay linear in the document; full re-parsing
  144. // would have accumulated ~40/2 times the document length here.
  145. const totalParsed = calls.reduce((sum, call) => sum + call.length, 0)
  146. expect(totalParsed).toBeLessThan(text.length * 5)
  147. })
  148. it('parses an open fence through bounded grammar slices as completed lines accumulate', () => {
  149. const calls: string[] = []
  150. const recording = (text: string): Root => {
  151. calls.push(text)
  152. return parseGfm(text)
  153. }
  154. const parser = new IncrementalMarkdownParser(recording)
  155. let text = '```ts\n'
  156. let result = parser.update(text)
  157. for (let index = 0; index < 800; index += 1) {
  158. text += `const value${String(index)} = ${String(index)}\n`
  159. result = parser.update(text)
  160. }
  161. const parsed = calls.reduce((sum, call) => sum + call.length, 0)
  162. expect(Math.max(...calls.slice(10).map(call => call.length))).toBeLessThan(80)
  163. expect(parsed).toBeLessThan(text.length * 4)
  164. expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children[0])
  165. })
  166. it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => {
  167. const doc = [
  168. '[ref]: https://example.com/target',
  169. '',
  170. 'Paragraph one keeps the definition company.',
  171. '',
  172. 'Paragraph two pushes the freeze boundary.',
  173. '',
  174. 'Paragraph three freezes the definition out.',
  175. '',
  176. 'See [the link][ref] for details.',
  177. ].join('\n')
  178. const head = doc.slice(0, doc.indexOf('See'))
  179. const live = render(<MarkdownText text={head} streaming />)
  180. live.rerender(<MarkdownText text={doc} streaming />)
  181. // The tail re-parse cannot see the frozen definition, so the reference
  182. // stays literal — the direct observable that the whole text was NOT
  183. // re-parsed (a one-shot mount of the same text resolves it).
  184. expect(live.container.querySelector('a')).toBeNull()
  185. expect(live.container.textContent).toContain('[the link][ref]')
  186. const fresh = render(<MarkdownText text={doc} streaming />)
  187. expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
  188. fresh.unmount()
  189. // The settled swap re-parses everything and heals the deviation.
  190. live.rerender(<MarkdownText text={doc} />)
  191. expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
  192. live.unmount()
  193. })
  194. })
  195. describe('freeze dynamics around frontier-sensitive constructs', () => {
  196. it('an unclosed fence pins the tail: nothing freezes until it closes', () => {
  197. const parser = new IncrementalMarkdownParser(parseGfm)
  198. let text = 'p1.\n\np2.\n\np3.\n\n```ts\n'
  199. const opened = parser.update(text)
  200. const frozenAtOpen = opened.frozen.length
  201. expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code')
  202. for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) {
  203. text += line
  204. const grown = parser.update(text)
  205. // The fence swallows everything appended, so the block census cannot
  206. // grow and the freeze boundary must hold still.
  207. expect(grown.frozen.length).toBe(frozenAtOpen)
  208. expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code')
  209. }
  210. text += '```\n\nafter one.\n\nafter two.\n'
  211. const closed = parser.update(text)
  212. expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen)
  213. const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node
  214. expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list')
  215. })
  216. it('keeps indented CRLF fence nodes equal to a fresh parse, then falls back when the fence closes', () => {
  217. const parser = new IncrementalMarkdownParser(parseGfm)
  218. const opening = 'p1.\n\np2.\n\np3.\n\n ```ts\r\n'
  219. const suffix = ' const a = 1\r\n const b = 2\r\n ```\r\nafter'
  220. let text = ''
  221. for (const char of `${opening}${suffix}`) {
  222. text += char
  223. const result = parser.update(text)
  224. const actual = [...result.frozen, ...result.tail].at(-1)
  225. const expected = parseGfm(text).children.at(-1)
  226. expect(actual?.key).toBe(expected?.position?.start.offset)
  227. expect(actual?.node.type).toBe(expected?.type)
  228. if (actual?.node.type === 'code' && expected?.type === 'code') {
  229. expect({ lang: actual.node.lang, meta: actual.node.meta, value: actual.node.value })
  230. .toEqual({ lang: expected.lang, meta: expected.meta, value: expected.value })
  231. }
  232. }
  233. })
  234. it('preserves lone-CR fence lines and ignores indented code as a fence frontier', () => {
  235. const parser = new IncrementalMarkdownParser(parseGfm)
  236. let text = '```ts\rfirst\r'
  237. parser.update(text)
  238. text += 'second\rthird'
  239. const result = parser.update(text)
  240. expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
  241. const indented = ' alpha\n beta\n'
  242. const indentedResult = new IncrementalMarkdownParser(parseGfm).update(indented)
  243. expect(indentedResult.tail.at(-1)?.node).toEqual(parseGfm(indented).children.at(-1))
  244. })
  245. it('falls back to the full grammar tail when a custom grammar rejects fence slices', () => {
  246. type Corruption = 'many' | 'paragraph' | 'mismatch'
  247. const custom = (corruption: Corruption): ((text: string) => Root) => (text) => {
  248. if (!text.startsWith('```\n')) return parseGfm(text)
  249. if (corruption === 'many') return parseGfm('one\n\ntwo')
  250. if (corruption === 'paragraph') return parseGfm('one')
  251. const root = parseGfm(text)
  252. const node = root.children[0]
  253. if (node?.type === 'code') node.value += 'mismatch'
  254. return root
  255. }
  256. const cases = [
  257. { corruption: 'many' as const, text: '```ts\nfirst' },
  258. { corruption: 'paragraph' as const, text: '```ts\nfirst' },
  259. { corruption: 'many' as const, text: '```ts\nfirst\nsecond\nthird' },
  260. { corruption: 'mismatch' as const, text: '```ts\nfirst' },
  261. ]
  262. for (const { corruption, text } of cases) {
  263. const result = new IncrementalMarkdownParser(custom(corruption)).update(text)
  264. expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
  265. }
  266. const positionless = new IncrementalMarkdownParser((text) => {
  267. const root = parseGfm(text)
  268. for (const node of root.children) delete node.position
  269. return root
  270. }).update('```ts\nfirst')
  271. expect(positionless.tail.at(-1)?.node.type).toBe('code')
  272. })
  273. it('abandons an installed fence frontier when later custom-grammar slices fail', () => {
  274. let syntheticCall = 0
  275. let reject: 'none' | 'first' | 'second' = 'none'
  276. const custom = (text: string): Root => {
  277. if (!text.startsWith('```\n')) return parseGfm(text)
  278. syntheticCall += 1
  279. if (reject === 'first' && syntheticCall === 1) return parseGfm('one\n\ntwo')
  280. if (reject === 'second' && syntheticCall === 2) return parseGfm('one\n\ntwo')
  281. return parseGfm(text)
  282. }
  283. const pendingParser = new IncrementalMarkdownParser(custom)
  284. let text = '```ts\nfirst\nsecond'
  285. pendingParser.update(text)
  286. syntheticCall = 0
  287. reject = 'first'
  288. text += ' tail'
  289. expect(pendingParser.update(text).tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
  290. reject = 'none'
  291. syntheticCall = 0
  292. const stableParser = new IncrementalMarkdownParser(custom)
  293. text = '```ts\nfirst\nsecond'
  294. stableParser.update(text)
  295. syntheticCall = 0
  296. reject = 'second'
  297. text += '\nthird\nfourth'
  298. expect(stableParser.update(text).tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
  299. })
  300. it('a list can keep extending across blank lines until it freezes whole', () => {
  301. const parser = new IncrementalMarkdownParser(parseGfm)
  302. let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n'
  303. const before = parser.update(text)
  304. const frozenBefore = before.frozen.length
  305. text += '\n- item c\n'
  306. const extended = parser.update(text)
  307. expect(extended.frozen.length).toBe(frozenBefore)
  308. const tailList = extended.tail[extended.tail.length - 1]?.node
  309. expect(tailList?.type === 'list' && tailList.children).toHaveLength(3)
  310. text += '\nafter.\n\nmore.\n\nend.\n'
  311. const after = parser.update(text)
  312. const frozenList = after.frozen.find(block => block.node.type === 'list')?.node
  313. expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3)
  314. })
  315. it('keeps every previously frozen key as a stable prefix across the stream', () => {
  316. const parser = new IncrementalMarkdownParser(parseGfm)
  317. let previous: readonly number[] = []
  318. for (let end = 7; end < STREAM_DOC.length + 7; end += 7) {
  319. const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)))
  320. const keys = frozen.map(block => block.key)
  321. expect(keys.slice(0, previous.length)).toEqual(previous)
  322. previous = keys
  323. }
  324. expect(previous.length).toBeGreaterThan(4)
  325. })
  326. })
  327. describe('multibyte content', () => {
  328. const CJK_DOC = [
  329. '# 标题 🎉',
  330. '',
  331. '这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。',
  332. '',
  333. '- 列表项一 ✅',
  334. '- 列表项二',
  335. '',
  336. '> 引用一行,带表情 🐟',
  337. '',
  338. '```',
  339. '中文代码 🎯',
  340. '```',
  341. '',
  342. '| 键 | 值 |',
  343. '| --- | --- |',
  344. '| 甲 | 乙 |',
  345. '',
  346. '结尾段落,足够多的块让前面全部冻结。🌊',
  347. ].join('\n')
  348. it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => {
  349. const live = render(<MarkdownText text="" streaming />)
  350. for (let end = 1; end < CJK_DOC.length + 1; end += 1) {
  351. const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))
  352. live.rerender(<MarkdownText text={prefix} streaming />)
  353. const fresh = render(<MarkdownText text={prefix} streaming />)
  354. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  355. fresh.unmount()
  356. }
  357. live.unmount()
  358. })
  359. it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => {
  360. const parser = new IncrementalMarkdownParser(parseGfm)
  361. let result = parser.update(CJK_DOC.slice(0, 3))
  362. for (let end = 6; end < CJK_DOC.length + 3; end += 3) {
  363. result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)))
  364. }
  365. const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset)
  366. expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot)
  367. expect(result.frozen.length).toBeGreaterThan(3)
  368. })
  369. })
  370. describe('streaming composition across freezes', () => {
  371. it('continues footnote numbering from frozen references and lists all definitions', () => {
  372. const doc = [
  373. 'Alpha uses a footnote[^a].',
  374. '',
  375. '[^a]: First note body.',
  376. '',
  377. 'Filler one.',
  378. '',
  379. 'Filler two.',
  380. '',
  381. 'Filler three.',
  382. '',
  383. 'Beta uses another[^b].',
  384. '',
  385. '[^b]: Second note body.',
  386. ].join('\n')
  387. const head = doc.slice(0, doc.indexOf('Beta'))
  388. const live = render(<MarkdownText text={head} streaming />)
  389. live.rerender(<MarkdownText text={doc} streaming />)
  390. expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2'])
  391. expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id))
  392. .toEqual(['user-content-fn-a', 'user-content-fn-b'])
  393. expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩')
  394. const fresh = render(<MarkdownText text={doc} streaming />)
  395. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  396. fresh.unmount()
  397. live.unmount()
  398. })
  399. it('keeps every frozen block DOM node through the rest of the stream', () => {
  400. const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`)
  401. const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n`
  402. const live = render(<MarkdownText text={half} streaming />)
  403. const captured = [...live.container.querySelectorAll('p')]
  404. expect(captured.length).toBe(6)
  405. let text = half
  406. for (const paragraph of paragraphs.slice(6)) {
  407. text += `${paragraph}\n\n`
  408. live.rerender(<MarkdownText text={text} streaming />)
  409. }
  410. const finalNodes = [...live.container.querySelectorAll('p')]
  411. expect(finalNodes.slice(0, 6)).toEqual(captured)
  412. expect(finalNodes).toHaveLength(12)
  413. live.unmount()
  414. })
  415. it('renders an empty document for definition-only streams, including trailing blank lines', () => {
  416. 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'
  417. const live = render(<MarkdownText text={doc.slice(0, 30)} streaming />)
  418. live.rerender(<MarkdownText text={doc} streaming />)
  419. live.rerender(<MarkdownText text={`${doc}\n\n\n`} streaming />)
  420. const fresh = render(<MarkdownText text={`${doc}\n\n\n`} streaming />)
  421. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  422. expect(live.container.querySelector('div')?.childNodes).toHaveLength(0)
  423. fresh.unmount()
  424. live.unmount()
  425. })
  426. it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => {
  427. const live = render(<MarkdownText text={'a.\n\nb.'} streaming />)
  428. live.rerender(<MarkdownText text={'a.\n\nb.'} />)
  429. const settled = render(<MarkdownText text={'a.\n\nb.'} />)
  430. expect(live.container.innerHTML).toBe(settled.container.innerHTML)
  431. settled.unmount()
  432. live.rerender(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
  433. const fresh = render(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
  434. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  435. fresh.unmount()
  436. live.unmount()
  437. })
  438. it('matches fresh renders under irregular deterministic chunk sizes', () => {
  439. let seed = 42
  440. const nextSize = (): number => {
  441. seed = (seed * 1103515245 + 12345) % 2147483648
  442. return 1 + (seed % 13)
  443. }
  444. const live = render(<MarkdownText text="" streaming />)
  445. let end = 0
  446. while (end < STREAM_DOC.length) {
  447. end = Math.min(end + nextSize(), STREAM_DOC.length)
  448. const prefix = STREAM_DOC.slice(0, end)
  449. live.rerender(<MarkdownText text={prefix} streaming />)
  450. const fresh = render(<MarkdownText text={prefix} streaming />)
  451. expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
  452. fresh.unmount()
  453. }
  454. live.unmount()
  455. })
  456. })
  457. describe('IncrementalMarkdownParser', () => {
  458. it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => {
  459. const parser = new IncrementalMarkdownParser(parseGfm)
  460. const first = parser.update('a\n\nb\n\nc\n\nd\n\ne')
  461. expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph'])
  462. expect(first.tail).toHaveLength(2)
  463. const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng')
  464. expect(second.frozen).toHaveLength(5)
  465. expect(second.tail).toHaveLength(2)
  466. // Previously returned frozen entries keep their identity and keys.
  467. expect(second.frozen.slice(0, 3)).toEqual(first.frozen)
  468. expect(second.generation).toBe(first.generation)
  469. })
  470. it('holds every block in the tail until more than two exist', () => {
  471. const parser = new IncrementalMarkdownParser(parseGfm)
  472. const result = parser.update('only\n\ntwo blocks')
  473. expect(result.frozen).toHaveLength(0)
  474. expect(result.tail).toHaveLength(2)
  475. })
  476. it('returns the cached result for identical input', () => {
  477. const parser = new IncrementalMarkdownParser(parseGfm)
  478. const first = parser.update('a\n\nb\n\nc')
  479. expect(parser.update('a\n\nb\n\nc')).toBe(first)
  480. })
  481. it('bumps the generation and discards frozen blocks on non-append input', () => {
  482. const parser = new IncrementalMarkdownParser(parseGfm)
  483. const before = parser.update('a\n\nb\n\nc\n\nd')
  484. expect(before.frozen.length).toBeGreaterThan(0)
  485. const after = parser.update('different')
  486. expect(after.generation).toBe(before.generation + 1)
  487. expect(after.frozen).toHaveLength(0)
  488. expect(after.tail.map(b => b.node.type)).toEqual(['paragraph'])
  489. })
  490. it('keys blocks by absolute source offset across freezes', () => {
  491. const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee'
  492. const parser = new IncrementalMarkdownParser(parseGfm)
  493. const grown = parser.update(doc)
  494. const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset)
  495. expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys)
  496. })
  497. it('never freezes under a grammar that omits positions', () => {
  498. const bare = (text: string): Root => {
  499. const root = parseGfm(text)
  500. const strip = (nodes: RootContent[]): void => {
  501. for (const node of nodes) {
  502. delete node.position
  503. if ('children' in node) strip(node.children)
  504. }
  505. }
  506. strip(root.children)
  507. return root
  508. }
  509. const parser = new IncrementalMarkdownParser(bare)
  510. const result = parser.update('a\n\nb\n\nc\n\nd\n\ne')
  511. expect(result.frozen).toHaveLength(0)
  512. expect(result.tail).toHaveLength(5)
  513. // Fallback keys stay unique per sibling.
  514. expect(new Set(result.tail.map(b => b.key)).size).toBe(5)
  515. })
  516. })