From 67e4e240732eb2535a99106924149238d623b9bc Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 12 Sep 2026 18:00:08 -0600 Subject: [PATCH] Speed up unfinished code fences without growing bundles --- .changeset/faster-streaming-fences.md | 7 + docs/comparison.md | 8 +- docs/guides/ai-streaming.md | 2 + docs/guides/performance.md | 12 +- package.json | 5 + pnpm-lock.yaml | 577 +++++++++++ reports/benchmarks.json | 1342 +++++++++++++++++++++--- reports/benchmarks.md | 225 ++-- reports/sizes.json | 68 +- reports/sizes.md | 24 +- reports/streaming-browser.json | 1381 +++++++++++++++++++++++++ reports/streaming-browser.md | 45 + reports/streaming-optimization.md | 30 + scripts/bench-streaming-browser.ts | 116 +++ scripts/bench.ts | 71 +- scripts/streaming-bench.ts | 123 +++ scripts/streaming-browser.tsx | 139 +++ scripts/verify-streaming-browser.ts | 20 + src/parser.ts | 261 ++--- src/react.ts | 30 +- tests/browser/streaming.tsx | 68 ++ tests/bundle-size.test.ts | 24 +- tests/streaming-bench.test.ts | 81 ++ 23 files changed, 4207 insertions(+), 452 deletions(-) create mode 100644 .changeset/faster-streaming-fences.md create mode 100644 reports/streaming-browser.json create mode 100644 reports/streaming-browser.md create mode 100644 reports/streaming-optimization.md create mode 100644 scripts/bench-streaming-browser.ts create mode 100644 scripts/streaming-bench.ts create mode 100644 scripts/streaming-browser.tsx create mode 100644 scripts/verify-streaming-browser.ts create mode 100644 tests/browser/streaming.tsx create mode 100644 tests/streaming-bench.test.ts diff --git a/.changeset/faster-streaming-fences.md b/.changeset/faster-streaming-fences.md new file mode 100644 index 0000000..a970ebb --- /dev/null +++ b/.changeset/faster-streaming-fences.md @@ -0,0 +1,7 @@ +--- +'@tanstack/markdown': patch +--- + +Speed up plain fenced-code rendering with the React streaming extension by retaining completed code text nodes across updates. Reduce parser overhead and shrink every affected public bundle without adding runtime dependencies. + +Add unfinished-fence benchmarks at 4, 16, and 64 KiB, browser comparisons with Streamdown and streaming-markdown, and regression coverage for streamed text, custom code components, and hydration. diff --git a/docs/comparison.md b/docs/comparison.md index e2cb35c..5acb030 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -40,11 +40,11 @@ These repository benchmarks bundle representative browser entry points from pinn | Entry | Gzip | Brotli | | --- | ---: | ---: | -| `@tanstack/markdown/parser` | 5.0 KB | 4.6 KB | -| `@tanstack/markdown/html` | 6.8 KB | 6.2 KB | +| `@tanstack/markdown/parser` | 4.9 KB | 4.5 KB | +| `@tanstack/markdown/html` | 6.7 KB | 6.2 KB | | `@tanstack/markdown/react` | 6.7 KB | 6.2 KB | -| React with streaming extension | 6.9 KB | 6.4 KB | -| `@tanstack/markdown/octane` | 6.7 KB | 6.2 KB | +| React with streaming extension | 6.9 KB | 6.3 KB | +| `@tanstack/markdown/octane` | 6.6 KB | 6.1 KB | | Marked | 12.5 KB | 11.5 KB | | micromark | 15.4 KB | 13.7 KB | | markdown-wasm JS + WASM | 31.3 KB | 26.4 KB | diff --git a/docs/guides/ai-streaming.md b/docs/guides/ai-streaming.md index 349196c..fb3d5fe 100644 --- a/docs/guides/ai-streaming.md +++ b/docs/guides/ai-streaming.md @@ -65,6 +65,8 @@ Unclosed emphasis, code spans, links, and other inline delimiters remain literal The package reparses the accumulated response rather than maintaining parser state between updates. Batch very small transport tokens into normal UI updates when responses are unusually long or tokens arrive faster than the screen should repaint. +With the streaming extension enabled, React keeps completed groups of plain code lines in stable text nodes. Appending code updates the trailing group instead of replacing the entire block's text, which reduces browser layout work. Custom code components still receive string children, and highlighters keep their existing HTML rendering path. + ## Security AI output is untrusted content: diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 070f162..d5bb2f6 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -12,11 +12,11 @@ The generated browser bundle report records: | Entry | Gzip | Brotli | | --- | ---: | ---: | -| parser | 5.0 KB | 4.6 KB | -| HTML renderer | 6.8 KB | 6.2 KB | +| parser | 4.9 KB | 4.5 KB | +| HTML renderer | 6.7 KB | 6.2 KB | | React adapter | 6.7 KB | 6.2 KB | -| Octane adapter | 6.7 KB | 6.2 KB | -| React adapter with streaming extension | 6.9 KB | 6.4 KB | +| Octane adapter | 6.6 KB | 6.1 KB | +| React adapter with streaming extension | 6.9 KB | 6.3 KB | | Streaming extension | 0.3 KB | 0.3 KB | | docs preset | 2.3 KB | 2.1 KB | | callouts extension | 0.3 KB | 0.3 KB | @@ -25,6 +25,10 @@ Framework runtimes are externalized from their adapters, and the highlighter mea The maintained benchmark records parse, pre-parsed rendering, parse-and-render, external-highlighter, and progressive AI-response paths against pinned comparison packages. Timings vary by runtime, CPU, fixture shape, and dependency version, so the generated [benchmark report](https://github.com/TanStack/markdown/blob/main/reports/benchmarks.md) is the only source of current CPU results. +Streaming cases include unfinished TypeScript fences at 4, 16, and 64 KiB, a tilde fence, and a fence that closes after 64 KiB. Every 32-character update reparses the accumulated source. The report records total replay time and per-update latency, including p50, p95, and maximum latency while the fence is open. Separate parse-only, plain HTML, and real `@tanstack/highlight` runs show how the costs change as code accumulates. These Node measurements exclude browser rendering and framework updates. + +The separate [streaming library comparison](https://github.com/TanStack/markdown/blob/main/reports/streaming-browser.md) measures TanStack Markdown React, Streamdown, and streaming-markdown in headless Chrome. It keeps React roots and incremental parser state alive between chunks, checks that the growing code stays visible, and records DOM update and forced-layout latency with plain code rendering. Run it with `pnpm run bench:streaming` after installing Chromium through `pnpm exec playwright install chromium`, or use an installed Chrome with `BENCH_BROWSER_CHANNEL=chrome pnpm run bench:streaming`. + ## Use the narrow entry point ```ts diff --git a/package.json b/package.json index b215175..9f45478 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:streaming-browser": "tsx scripts/verify-streaming-browser.ts", "test:corpus": "vitest run tests/corpus.test.tsx", "corpus:audit": "tsx scripts/audit-corpus.ts", "corpus:audit:tanstack": "tsx scripts/audit-tanstack-corpus.ts", @@ -90,6 +91,7 @@ "release:publish": "pnpm run verify && changeset publish", "conformance": "tsx scripts/conformance.ts", "bench": "tsx scripts/bench.ts", + "bench:streaming": "tsx scripts/bench-streaming-browser.ts", "size": "tsx scripts/measure-size.ts", "research": "pnpm run conformance && pnpm run size && pnpm run bench", "verify": "pnpm test && pnpm run typecheck && pnpm run build && pnpm run docs:verify && pnpm run test:skills && pnpm run research && pnpm pack --dry-run", @@ -126,11 +128,14 @@ "marked": "^18.0.5", "micromark": "^4.0.2", "octane": "0.1.12", + "playwright": "1.63.0", "react": "^19.0.0", "react-dom": "^19.0.0", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "streamdown": "2.6.0", + "streaming-markdown": "0.2.15", "tsx": "^4.20.0", "typescript": "^5.8.0", "unified": "^11.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b62a5ef..625b64c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: octane: specifier: 0.1.12 version: 0.1.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite@7.3.5(@types/node@24.13.2)(tsx@4.22.4)(yaml@2.9.0)) + playwright: + specifier: 1.63.0 + version: 1.63.0 react: specifier: ^19.0.0 version: 19.2.7 @@ -65,6 +68,12 @@ importers: remark-rehype: specifier: ^11.1.2 version: 11.1.2 + streamdown: + specifier: 2.6.0 + version: 2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + streaming-markdown: + specifier: 0.2.15 + version: 0.2.15 tsx: specifier: ^4.20.0 version: 4.22.4 @@ -829,6 +838,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -903,10 +915,17 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -954,6 +973,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -972,6 +995,10 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + esrap@2.3.0: resolution: {integrity: sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==} peerDependencies: @@ -980,6 +1007,9 @@ packages: '@typescript-eslint/types': optional: true + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1013,12 +1043,36 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -1029,6 +1083,21 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -1051,6 +1120,9 @@ packages: linkify-it@5.0.1: resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1061,20 +1133,64 @@ packages: resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + markdown-wasm@1.2.0: resolution: {integrity: sha512-S12OTkyXCkOgI1n1rZY9cg4bK/PGu80Emjpvwp8BEjwCxhPV3yddF0U6+QhCitdBsI1tzWcoeahmW7k0Pq81OA==} + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} hasBin: true + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} @@ -1087,6 +1203,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -1176,6 +1313,12 @@ packages: package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1190,6 +1333,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1210,15 +1363,33 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + rehype-harden@1.1.8: + resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remend@1.3.1: + resolution: {integrity: sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==} + rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1258,12 +1429,31 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamdown@2.6.0: + resolution: {integrity: sha512-nQZVUn4GvB2R5SAlDNph20iKZeW+RM4gv6G1H3ACypEnmQf8PgTHZ/1Ta2OACRwQ2coK7aW5AeIAa7Ls5zk+RA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + streaming-markdown@0.2.15: + resolution: {integrity: sha512-eSWjdvLSTznl2+FIzuxw1TRXwhjZiY1RfSygGCMpcNY8CrsaUM2zHtxe/JFcPoXCQxeO3TSJ72QMDIN5B/Ui1w==} + engines: {node: '>=20'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1330,6 +1520,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -1409,6 +1602,9 @@ packages: jsdom: optional: true + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1941,6 +2137,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@ungap/structured-clone@1.3.1': {} @@ -2015,8 +2213,12 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + clsx@2.1.1: {} + comma-separated-tokens@2.0.3: {} commonmark-spec@0.31.2: {} @@ -2051,6 +2253,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.25.12: @@ -2140,10 +2344,14 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@5.0.0: {} + esrap@2.3.0: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2169,6 +2377,43 @@ snapshots: fsevents@2.3.3: optional: true + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.1 + unist-util-position: 5.0.0 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -2183,16 +2428,69 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} human-id@4.2.1: {} import-meta-resolve@4.2.0: {} + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + is-plain-obj@4.1.0: {} is-reference@3.0.3: @@ -2214,6 +2512,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + longest-streak@3.1.0: {} + loupe@3.2.1: {} magic-string@0.30.21: @@ -2229,10 +2529,21 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-table@3.0.4: {} + markdown-wasm@1.2.0: {} + marked@17.0.6: {} + marked@18.0.5: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -2250,6 +2561,107 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -2262,6 +2674,18 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -2289,6 +2713,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -2424,6 +2906,20 @@ snapshots: package-manager-detector@1.8.0: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -2432,6 +2928,12 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss@8.5.15: dependencies: nanoid: 3.3.13 @@ -2449,12 +2951,38 @@ snapshots: react@19.2.7: {} + rehype-harden@1.1.8: + dependencies: + unist-util-visit: 5.1.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 unified: 11.0.5 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -2472,6 +3000,14 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remend@1.3.1: {} + rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -2523,6 +3059,30 @@ snapshots: std-env@4.2.0: {} + streamdown@2.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + rehype-harden: 1.1.8 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.1 + tailwind-merge: 3.6.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + streaming-markdown@0.2.15: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -2532,6 +3092,16 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + tailwind-merge@3.6.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2598,6 +3168,11 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -2685,6 +3260,8 @@ snapshots: - tsx - yaml + web-namespaces@2.0.1: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/reports/benchmarks.json b/reports/benchmarks.json index 1ba7450..ba5f8f8 100644 --- a/reports/benchmarks.json +++ b/reports/benchmarks.json @@ -1,6 +1,12 @@ { - "generatedAt": "2026-09-11T20:23:30.357Z", - "sink": 112892330, + "generatedAt": "2026-09-12T23:59:58.621Z", + "environment": { + "node": "v24.15.0", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M5 Pro" + }, + "sink": 9056185263, "results": [ { "group": "markdown", @@ -8,9 +14,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.01844806249999999, + "msPerOp": 0.0230959165, "outputBytes": 15, - "heapDeltaKb": 898.9609375 + "heapDeltaKb": 35036.625 }, { "group": "markdown", @@ -18,9 +24,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.005263854499999994, + "msPerOp": 0.004967021000000003, "outputBytes": 1399, - "heapDeltaKb": 1238.703125 + "heapDeltaKb": 10160.7421875 }, { "group": "markdown", @@ -28,9 +34,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.00399779199999999, + "msPerOp": 0.003822896, "outputBytes": 1143, - "heapDeltaKb": -3027.4296875 + "heapDeltaKb": 133.8359375 }, { "group": "markdown", @@ -38,9 +44,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.023797770500000013, + "msPerOp": 0.025145104000000005, "outputBytes": 1399, - "heapDeltaKb": -2596.171875 + "heapDeltaKb": 11894.5078125 }, { "group": "markdown", @@ -48,9 +54,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.023606604000000003, + "msPerOp": 0.023205708500000016, "outputBytes": 1143, - "heapDeltaKb": -1860.5859375 + "heapDeltaKb": 44793.6796875 }, { "group": "markdown", @@ -58,9 +64,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.02952339599999999, + "msPerOp": 0.029327332999999983, "outputBytes": 1035, - "heapDeltaKb": -853.609375 + "heapDeltaKb": -92849.1171875 }, { "group": "markdown", @@ -68,9 +74,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.019339646000000016, + "msPerOp": 0.018661083499999988, "outputBytes": 1087, - "heapDeltaKb": 4088.890625 + "heapDeltaKb": 44907.1484375 }, { "group": "markdown", @@ -78,9 +84,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.17314687500000003, + "msPerOp": 0.16640525, "outputBytes": 825, - "heapDeltaKb": -8298.203125 + "heapDeltaKb": 51435.609375 }, { "group": "markdown", @@ -88,9 +94,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.021329833500000006, + "msPerOp": 0.013089541999999994, "outputBytes": 825, - "heapDeltaKb": 54.8671875 + "heapDeltaKb": -49166.7734375 }, { "group": "markdown", @@ -98,9 +104,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.010540166499999998, + "msPerOp": 0.008372937499999978, "outputBytes": 1117, - "heapDeltaKb": 4372.3984375 + "heapDeltaKb": 4376.671875 }, { "group": "markdown", @@ -108,9 +114,9 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 2000, - "msPerOp": 0.19910575, + "msPerOp": 0.21472016700000002, "outputBytes": 824, - "heapDeltaKb": 13756 + "heapDeltaKb": 7854.609375 }, { "group": "markdown", @@ -118,9 +124,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.006807167000000163, + "msPerOp": 0.00930816600000003, "outputBytes": 15, - "heapDeltaKb": -6378.1484375 + "heapDeltaKb": 23903.4453125 }, { "group": "markdown", @@ -128,9 +134,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.007484500000000026, + "msPerOp": 0.008788749999999936, "outputBytes": 4596, - "heapDeltaKb": -1122.5703125 + "heapDeltaKb": -30194.4296875 }, { "group": "markdown", @@ -138,9 +144,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.003155708999999888, + "msPerOp": 0.0040034169999999, "outputBytes": 1927, - "heapDeltaKb": 10476.2890625 + "heapDeltaKb": 10476.1640625 }, { "group": "markdown", @@ -148,9 +154,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.014249915999999984, + "msPerOp": 0.01934395799999993, "outputBytes": 4596, - "heapDeltaKb": -6339.6796875 + "heapDeltaKb": -5389.796875 }, { "group": "markdown", @@ -158,9 +164,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.009854332999999996, + "msPerOp": 0.01655070899999987, "outputBytes": 1927, - "heapDeltaKb": 4603.2109375 + "heapDeltaKb": -25821.4453125 }, { "group": "markdown", @@ -168,9 +174,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.006169208000000026, + "msPerOp": 0.007307250000000067, "outputBytes": 1330, - "heapDeltaKb": -19757.0859375 + "heapDeltaKb": 12886.8359375 }, { "group": "markdown", @@ -178,9 +184,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.008338584000000083, + "msPerOp": 0.009848250000000008, "outputBytes": 1330, - "heapDeltaKb": -5321.0390625 + "heapDeltaKb": 27313.4140625 }, { "group": "markdown", @@ -188,9 +194,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.13680366700000013, + "msPerOp": 0.16169662500000004, "outputBytes": 1330, - "heapDeltaKb": -11833.328125 + "heapDeltaKb": -5850.4296875 }, { "group": "markdown", @@ -198,9 +204,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.009240166999999928, + "msPerOp": 0.010917582999999922, "outputBytes": 1330, - "heapDeltaKb": 24577.0234375 + "heapDeltaKb": -40875.984375 }, { "group": "markdown", @@ -208,9 +214,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.004289874999999938, + "msPerOp": 0.0053693749999999905, "outputBytes": 1512, - "heapDeltaKb": 2559.9921875 + "heapDeltaKb": 2558.296875 }, { "group": "markdown", @@ -218,9 +224,9 @@ "fixture": "code-heavy.md", "bytes": 1011, "iterations": 1000, - "msPerOp": 0.15214312500000005, + "msPerOp": 0.15673112500000003, "outputBytes": 1200, - "heapDeltaKb": -7037.953125 + "heapDeltaKb": 24973.7109375 }, { "group": "markdown", @@ -228,9 +234,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.0027057704999999713, + "msPerOp": 0.002730000000000018, "outputBytes": 15, - "heapDeltaKb": 18580.609375 + "heapDeltaKb": -44947.8828125 }, { "group": "markdown", @@ -238,9 +244,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.0016762294999999768, + "msPerOp": 0.001634728999999993, "outputBytes": 1067, - "heapDeltaKb": -17067.3203125 + "heapDeltaKb": 15659.734375 }, { "group": "markdown", @@ -248,9 +254,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.0007344790000000785, + "msPerOp": 0.0006929999999999837, "outputBytes": 361, - "heapDeltaKb": 5314.8828125 + "heapDeltaKb": 5315.171875 }, { "group": "markdown", @@ -258,9 +264,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.004606062500000007, + "msPerOp": 0.004589437500000031, "outputBytes": 1067, - "heapDeltaKb": 1452.21875 + "heapDeltaKb": 33660.75 }, { "group": "markdown", @@ -268,9 +274,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.0036469999999999346, + "msPerOp": 0.00380916650000006, "outputBytes": 361, - "heapDeltaKb": -8892.2109375 + "heapDeltaKb": -34278.9375 }, { "group": "markdown", @@ -278,9 +284,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.005531416499999977, + "msPerOp": 0.005420104499999979, "outputBytes": 350, - "heapDeltaKb": 16140.453125 + "heapDeltaKb": 16657.734375 }, { "group": "markdown", @@ -288,9 +294,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.005058562499999994, + "msPerOp": 0.005576854499999968, "outputBytes": 300, - "heapDeltaKb": -7253.6015625 + "heapDeltaKb": -31630.0625 }, { "group": "markdown", @@ -298,9 +304,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.05207070850000002, + "msPerOp": 0.05042335449999996, "outputBytes": 300, - "heapDeltaKb": 7694.546875 + "heapDeltaKb": 11184.7890625 }, { "group": "markdown", @@ -308,9 +314,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.0038984790000000658, + "msPerOp": 0.003881750000000011, "outputBytes": 300, - "heapDeltaKb": -9655.921875 + "heapDeltaKb": 23073.0234375 }, { "group": "markdown", @@ -318,9 +324,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.0018649789999999485, + "msPerOp": 0.001838145499999996, "outputBytes": 408, - "heapDeltaKb": 2938.3984375 + "heapDeltaKb": 2938.671875 }, { "group": "markdown", @@ -328,9 +334,9 @@ "fixture": "malformed.md", "bytes": 237, "iterations": 2000, - "msPerOp": 0.06917156250000005, + "msPerOp": 0.055197812500000054, "outputBytes": 297, - "heapDeltaKb": 2219.1953125 + "heapDeltaKb": -30681.640625 }, { "group": "markdown", @@ -338,9 +344,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.017162290999999868, + "msPerOp": 0.018113207999999985, "outputBytes": 15, - "heapDeltaKb": -17554.0625 + "heapDeltaKb": 1601.375 }, { "group": "markdown", @@ -348,9 +354,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.002673625000000129, + "msPerOp": 0.002141417000000047, "outputBytes": 1903, - "heapDeltaKb": 10295.9765625 + "heapDeltaKb": 10292.2421875 }, { "group": "markdown", @@ -358,9 +364,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.002687332999999853, + "msPerOp": 0.002436666999999943, "outputBytes": 1903, - "heapDeltaKb": 10321.3203125 + "heapDeltaKb": 10320.4609375 }, { "group": "markdown", @@ -368,9 +374,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.017694124999999984, + "msPerOp": 0.02010304199999996, "outputBytes": 1903, - "heapDeltaKb": -7354.9765625 + "heapDeltaKb": 18362.328125 }, { "group": "markdown", @@ -378,9 +384,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.016582792000000155, + "msPerOp": 0.020281833000000006, "outputBytes": 1903, - "heapDeltaKb": -7244.9453125 + "heapDeltaKb": 18847.21875 }, { "group": "markdown", @@ -388,9 +394,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.03447937500000012, + "msPerOp": 0.03812349999999992, "outputBytes": 1860, - "heapDeltaKb": 6729.9375 + "heapDeltaKb": -5593.0234375 }, { "group": "markdown", @@ -398,9 +404,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.01885091699999998, + "msPerOp": 0.02060416699999996, "outputBytes": 1860, - "heapDeltaKb": -18343.15625 + "heapDeltaKb": -8952.140625 }, { "group": "markdown", @@ -408,9 +414,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.24291566599999986, + "msPerOp": 0.2549411249999998, "outputBytes": 1862, - "heapDeltaKb": 24865.828125 + "heapDeltaKb": 21780.578125 }, { "group": "markdown", @@ -418,9 +424,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.011920082999999977, + "msPerOp": 0.013604624999999941, "outputBytes": 1862, - "heapDeltaKb": 46458.671875 + "heapDeltaKb": -14599.390625 }, { "group": "markdown", @@ -428,9 +434,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.006297250000000076, + "msPerOp": 0.006443499999999858, "outputBytes": 2340, - "heapDeltaKb": 3368.3671875 + "heapDeltaKb": 3369.3828125 }, { "group": "markdown", @@ -438,9 +444,9 @@ "fixture": "prose-heavy.md", "bytes": 1700, "iterations": 1000, - "msPerOp": 0.28966795899999986, + "msPerOp": 0.3224618330000003, "outputBytes": 1859, - "heapDeltaKb": 50897.1328125 + "heapDeltaKb": -73200.890625 }, { "group": "markdown", @@ -448,9 +454,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.0093737500000002, + "msPerOp": 0.01270204149999995, "outputBytes": 15, - "heapDeltaKb": 7577.4453125 + "heapDeltaKb": 20112.46875 }, { "group": "markdown", @@ -458,9 +464,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.003080417000000125, + "msPerOp": 0.004287021000000096, "outputBytes": 1370, - "heapDeltaKb": 27814.0859375 + "heapDeltaKb": -24522.9140625 }, { "group": "markdown", @@ -468,9 +474,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.0026590625000001184, + "msPerOp": 0.0027589165000001686, "outputBytes": 1044, - "heapDeltaKb": -42766.875 + "heapDeltaKb": 21772.9453125 }, { "group": "markdown", @@ -478,9 +484,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.012628958000000011, + "msPerOp": 0.016400666499999942, "outputBytes": 1370, - "heapDeltaKb": 31414.875 + "heapDeltaKb": -9773.921875 }, { "group": "markdown", @@ -488,9 +494,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.012084312499999895, + "msPerOp": 0.016765562499999987, "outputBytes": 1044, - "heapDeltaKb": -40133.328125 + "heapDeltaKb": -7187.6171875 }, { "group": "markdown", @@ -498,9 +504,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.012065562499999943, + "msPerOp": 0.014704625000000079, "outputBytes": 724, - "heapDeltaKb": 50632.2109375 + "heapDeltaKb": 50669.171875 }, { "group": "markdown", @@ -508,9 +514,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.00776693750000004, + "msPerOp": 0.008663270999999894, "outputBytes": 776, - "heapDeltaKb": 3160.15625 + "heapDeltaKb": 4037.9140625 }, { "group": "markdown", @@ -518,9 +524,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.09741200000000004, + "msPerOp": 0.13299991649999993, "outputBytes": 535, - "heapDeltaKb": 44970.375 + "heapDeltaKb": 44880.390625 }, { "group": "markdown", @@ -528,9 +534,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.007062458500000048, + "msPerOp": 0.007399374999999963, "outputBytes": 535, - "heapDeltaKb": -15145.1796875 + "heapDeltaKb": -15136.8671875 }, { "group": "markdown", @@ -538,9 +544,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.003928562499999998, + "msPerOp": 0.0036898334999998497, "outputBytes": 854, - "heapDeltaKb": 3580.1796875 + "heapDeltaKb": 3631.15625 }, { "group": "markdown", @@ -548,9 +554,9 @@ "fixture": "small-doc.md", "bytes": 432, "iterations": 2000, - "msPerOp": 0.129259542, + "msPerOp": 0.1475164164999999, "outputBytes": 534, - "heapDeltaKb": 4179.84375 + "heapDeltaKb": 4013.40625 }, { "group": "markdown", @@ -558,9 +564,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.0232009579999999, + "msPerOp": 0.029478124999999862, "outputBytes": 15, - "heapDeltaKb": -43744.359375 + "heapDeltaKb": 12749.0625 }, { "group": "markdown", @@ -568,9 +574,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.004354895999999826, + "msPerOp": 0.005765270999999984, "outputBytes": 1315, - "heapDeltaKb": 42277.9921875 + "heapDeltaKb": -5483.21875 }, { "group": "markdown", @@ -578,9 +584,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.00404427099999998, + "msPerOp": 0.005156958500000201, "outputBytes": 1315, - "heapDeltaKb": -23161.15625 + "heapDeltaKb": -14400.9453125 }, { "group": "markdown", @@ -588,9 +594,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.02736354150000011, + "msPerOp": 0.036605395499999985, "outputBytes": 1315, - "heapDeltaKb": -1853.140625 + "heapDeltaKb": 6365.84375 }, { "group": "markdown", @@ -598,9 +604,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.027197875000000066, + "msPerOp": 0.03830779150000012, "outputBytes": 1315, - "heapDeltaKb": -2112.7109375 + "heapDeltaKb": -91885.7890625 }, { "group": "markdown", @@ -608,9 +614,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.029182874999999966, + "msPerOp": 0.03246374999999989, "outputBytes": 1102, - "heapDeltaKb": -14329.2421875 + "heapDeltaKb": -2774.734375 }, { "group": "markdown", @@ -618,9 +624,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.017972145500000123, + "msPerOp": 0.021280750000000126, "outputBytes": 1325, - "heapDeltaKb": 26395.5078125 + "heapDeltaKb": 26335.390625 }, { "group": "markdown", @@ -628,9 +634,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.16772906250000005, + "msPerOp": 0.18304518749999987, "outputBytes": 627, - "heapDeltaKb": -39153.3359375 + "heapDeltaKb": 43377.28125 }, { "group": "markdown", @@ -638,9 +644,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.011388625000000048, + "msPerOp": 0.011697375000000192, "outputBytes": 627, - "heapDeltaKb": 28363.8125 + "heapDeltaKb": -36650.7734375 }, { "group": "markdown", @@ -648,9 +654,9 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.005910791999999901, + "msPerOp": 0.005767812500000218, "outputBytes": 1202, - "heapDeltaKb": 4506.171875 + "heapDeltaKb": 4506.0546875 }, { "group": "markdown", @@ -658,9 +664,54 @@ "fixture": "tables-lists.md", "bytes": 454, "iterations": 2000, - "msPerOp": 0.1964307080000001, + "msPerOp": 0.20902820799999972, "outputBytes": 622, - "heapDeltaKb": 7601.8671875 + "heapDeltaKb": -44097.953125 + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming parse", + "fixture": "ai-response.md", + "bytes": 652, + "iterations": 250, + "msPerOp": 0.21409083199997986, + "outputBytes": 0, + "heapDeltaKb": 27889.125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 21, + "openFenceUpdatesPerReplay": 2, + "checksum": 26208, + "latency": { + "all": { + "samples": 5250, + "meanMs": 0.010083434857144831, + "p50Ms": 0.007499999999708962, + "p95Ms": 0.015916999999717518, + "maxMs": 2.5200840000006792 + }, + "openFence": { + "samples": 500, + "meanMs": 0.006220576000010624, + "p50Ms": 0.0058330000001660665, + "p95Ms": 0.0071250000000873115, + "maxMs": 0.04941599999983737 + }, + "lastTenPercentOpenFence": { + "samples": 250, + "meanMs": 0.0062961639999884935, + "p50Ms": 0.005834000000504602, + "p95Ms": 0.0070420000001831795, + "maxMs": 0.04941599999983737 + }, + "finalUpdate": { + "samples": 250, + "meanMs": 0.015879831999976887, + "p50Ms": 0.014874999999847205, + "p95Ms": 0.01895799999965675, + "maxMs": 0.0702080000000933 + } + } }, { "group": "streaming", @@ -668,9 +719,89 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 250, - "msPerOp": 0.22356500000000232, + "msPerOp": 0.2578975960000098, "outputBytes": 1112, - "heapDeltaKb": -8191.453125 + "heapDeltaKb": 65860.796875, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 21, + "openFenceUpdatesPerReplay": 2, + "checksum": 3173184, + "latency": { + "all": { + "samples": 5250, + "meanMs": 0.012171573523811232, + "p50Ms": 0.00891700000011042, + "p95Ms": 0.019290999999611813, + "maxMs": 2.499333000000661 + }, + "openFence": { + "samples": 500, + "meanMs": 0.007640497999973377, + "p50Ms": 0.00724999999965803, + "p95Ms": 0.008667000000059488, + "maxMs": 0.052958000000217 + }, + "lastTenPercentOpenFence": { + "samples": 250, + "meanMs": 0.007983831999979884, + "p50Ms": 0.00741699999980483, + "p95Ms": 0.009125000000494765, + "maxMs": 0.052958000000217 + }, + "finalUpdate": { + "samples": 250, + "meanMs": 0.026833788000047206, + "p50Ms": 0.018500000000130967, + "p95Ms": 0.02104200000030687, + "maxMs": 1.980333000000428 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming with @tanstack/highlight", + "fixture": "ai-response.md", + "bytes": 652, + "iterations": 250, + "msPerOp": 0.38105232800001976, + "outputBytes": 1274, + "heapDeltaKb": 26043.859375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 21, + "openFenceUpdatesPerReplay": 2, + "checksum": 3703896, + "latency": { + "all": { + "samples": 5250, + "meanMs": 0.01806752114286272, + "p50Ms": 0.017582999999831372, + "p95Ms": 0.029458999999405933, + "maxMs": 2.0387080000000424 + }, + "openFence": { + "samples": 500, + "meanMs": 0.019441631999998207, + "p50Ms": 0.012625000000298314, + "p95Ms": 0.01741700000002311, + "maxMs": 1.8179999999993015 + }, + "lastTenPercentOpenFence": { + "samples": 250, + "meanMs": 0.02747746000004554, + "p50Ms": 0.01341599999977916, + "p95Ms": 0.02008399999976973, + "maxMs": 1.8179999999993015 + }, + "finalUpdate": { + "samples": 250, + "meanMs": 0.03449262800000724, + "p50Ms": 0.026125000000320142, + "p95Ms": 0.03470800000013696, + "maxMs": 1.784125000000131 + } + } }, { "group": "streaming", @@ -678,9 +809,944 @@ "fixture": "ai-response.md", "bytes": 652, "iterations": 250, - "msPerOp": 0.2804184999999998, + "msPerOp": 0.3046166559999801, "outputBytes": 1035, - "heapDeltaKb": 3232.0078125 + "heapDeltaKb": 19526.328125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 21, + "openFenceUpdatesPerReplay": 2, + "checksum": 2962764, + "latency": { + "all": { + "samples": 5250, + "meanMs": 0.014406752571436213, + "p50Ms": 0.013124999999490683, + "p95Ms": 0.025375000000167347, + "maxMs": 1.5779169999996157 + }, + "openFence": { + "samples": 500, + "meanMs": 0.01029838800001744, + "p50Ms": 0.009833000000071479, + "p95Ms": 0.013332999999875028, + "maxMs": 0.02525000000059663 + }, + "lastTenPercentOpenFence": { + "samples": 250, + "meanMs": 0.0104275000000016, + "p50Ms": 0.009957999999642198, + "p95Ms": 0.013832999999976892, + "maxMs": 0.019166999999470136 + }, + "finalUpdate": { + "samples": 250, + "meanMs": 0.024599832000021707, + "p50Ms": 0.02345900000000256, + "p95Ms": 0.02995900000041729, + "maxMs": 0.05720800000017334 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming parse", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "iterations": 20, + "msPerOp": 0.4621396000000459, + "outputBytes": 0, + "heapDeltaKb": -34834.6953125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 128, + "openFenceUpdatesPerReplay": 127, + "checksum": 5632, + "latency": { + "all": { + "samples": 2560, + "meanMs": 0.0034755386718696712, + "p50Ms": 0.0030829999996058177, + "p95Ms": 0.005083000000013271, + "maxMs": 0.6201670000000377 + }, + "openFence": { + "samples": 2540, + "meanMs": 0.0034912744094433615, + "p50Ms": 0.0030830000005153124, + "p95Ms": 0.005083000000013271, + "maxMs": 0.6201670000000377 + }, + "lastTenPercentOpenFence": { + "samples": 260, + "meanMs": 0.005197099999976182, + "p50Ms": 0.004874999999628926, + "p95Ms": 0.006124999999883585, + "maxMs": 0.020915999999488122 + }, + "finalUpdate": { + "samples": 20, + "meanMs": 0.005047750000085216, + "p50Ms": 0.005000000000109139, + "p95Ms": 0.005541000000448548, + "maxMs": 0.005624999999781721 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming profile", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "iterations": 20, + "msPerOp": 0.860104199999887, + "outputBytes": 4731, + "heapDeltaKb": 695.4375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 128, + "openFenceUpdatesPerReplay": 127, + "checksum": 6817030, + "latency": { + "all": { + "samples": 2560, + "meanMs": 0.006617728125001321, + "p50Ms": 0.005625000000691216, + "p95Ms": 0.009707999999591266, + "maxMs": 2.0296669999997903 + }, + "openFence": { + "samples": 2540, + "meanMs": 0.006656301574804367, + "p50Ms": 0.0056670000003578025, + "p95Ms": 0.009708999999929802, + "maxMs": 2.0296669999997903 + }, + "lastTenPercentOpenFence": { + "samples": 260, + "meanMs": 0.00977183846150113, + "p50Ms": 0.00908400000025722, + "p95Ms": 0.013374999999541615, + "maxMs": 0.05837500000052387 + }, + "finalUpdate": { + "samples": 20, + "meanMs": 0.010637499999938882, + "p50Ms": 0.009500000000116415, + "p95Ms": 0.014249999999265128, + "maxMs": 0.017416000000594067 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming with @tanstack/highlight", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "iterations": 20, + "msPerOp": 22.06873735000004, + "outputBytes": 16010, + "heapDeltaKb": 62990.421875, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 128, + "openFenceUpdatesPerReplay": 127, + "checksum": 22759572, + "latency": { + "all": { + "samples": 2560, + "meanMs": 0.17212833007812875, + "p50Ms": 0.16845800000010058, + "p95Ms": 0.32120800000029703, + "maxMs": 0.7369170000001759 + }, + "openFence": { + "samples": 2540, + "meanMs": 0.17335691614173696, + "p50Ms": 0.16941600000063772, + "p95Ms": 0.32133299999986775, + "maxMs": 0.7369170000001759 + }, + "lastTenPercentOpenFence": { + "samples": 260, + "meanMs": 0.32346092692306716, + "p50Ms": 0.31554099999993923, + "p95Ms": 0.3592500000004293, + "maxMs": 0.7194170000002487 + }, + "finalUpdate": { + "samples": 20, + "meanMs": 0.35081459999978504, + "p50Ms": 0.33216600000014296, + "p95Ms": 0.36524999999983265, + "maxMs": 0.6940829999994094 + } + } + }, + { + "group": "streaming", + "name": "marked progressive parse+render", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "iterations": 20, + "msPerOp": 0.7148749500001031, + "outputBytes": 4702, + "heapDeltaKb": -36811.359375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 128, + "openFenceUpdatesPerReplay": 127, + "checksum": 6735366, + "latency": { + "all": { + "samples": 2560, + "meanMs": 0.005508160156259834, + "p50Ms": 0.005333999999493244, + "p95Ms": 0.009250000000065484, + "maxMs": 0.3232909999996991 + }, + "openFence": { + "samples": 2540, + "meanMs": 0.005541573228355639, + "p50Ms": 0.005375000000640284, + "p95Ms": 0.009250000000065484, + "maxMs": 0.3232909999996991 + }, + "lastTenPercentOpenFence": { + "samples": 260, + "meanMs": 0.010631888461564534, + "p50Ms": 0.009125000000494765, + "p95Ms": 0.01116699999965931, + "maxMs": 0.3232909999996991 + }, + "finalUpdate": { + "samples": 20, + "meanMs": 0.009675050000214468, + "p50Ms": 0.009500000000116415, + "p95Ms": 0.010333000000173342, + "maxMs": 0.010709000000133528 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming parse", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "iterations": 10, + "msPerOp": 5.848141600000053, + "outputBytes": 0, + "heapDeltaKb": -165018.1953125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 512, + "openFenceUpdatesPerReplay": 511, + "checksum": 12288, + "latency": { + "all": { + "samples": 5120, + "meanMs": 0.011320061523442249, + "p50Ms": 0.009500000000116415, + "p95Ms": 0.018124999999599822, + "maxMs": 2.3499999999994543 + }, + "openFence": { + "samples": 5110, + "meanMs": 0.011338096477499627, + "p50Ms": 0.009500000000116415, + "p95Ms": 0.018124999999599822, + "maxMs": 2.3499999999994543 + }, + "lastTenPercentOpenFence": { + "samples": 520, + "meanMs": 0.025568692307708193, + "p50Ms": 0.016749999999774445, + "p95Ms": 0.02420799999981682, + "maxMs": 2.262917000000016 + }, + "finalUpdate": { + "samples": 10, + "meanMs": 0.022812400000020716, + "p50Ms": 0.017582999999831372, + "p95Ms": 0.05129099999976461, + "maxMs": 0.05129099999976461 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming profile", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "iterations": 10, + "msPerOp": 10.330145699999866, + "outputBytes": 18667, + "heapDeltaKb": 66138.4765625, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 512, + "openFenceUpdatesPerReplay": 511, + "checksum": 57732972, + "latency": { + "all": { + "samples": 5120, + "meanMs": 0.02006602402343489, + "p50Ms": 0.018874999999752617, + "p95Ms": 0.03450000000066211, + "maxMs": 1.5924589999995078 + }, + "openFence": { + "samples": 5110, + "meanMs": 0.02009172387475263, + "p50Ms": 0.018915999999990163, + "p95Ms": 0.03450000000066211, + "maxMs": 1.5924589999995078 + }, + "lastTenPercentOpenFence": { + "samples": 520, + "meanMs": 0.036632036538470365, + "p50Ms": 0.033250000000407454, + "p95Ms": 0.041041999999833934, + "maxMs": 1.2621659999995245 + }, + "finalUpdate": { + "samples": 10, + "meanMs": 0.036491599999953904, + "p50Ms": 0.03608399999939138, + "p95Ms": 0.0415419999999358, + "maxMs": 0.0415419999999358 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming with @tanstack/highlight", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "iterations": 10, + "msPerOp": 433.25519150000036, + "outputBytes": 62868, + "heapDeltaKb": 35834.84375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 512, + "openFenceUpdatesPerReplay": 511, + "checksum": 194725992, + "latency": { + "all": { + "samples": 5120, + "meanMs": 0.8447549888671946, + "p50Ms": 0.8175839999994423, + "p95Ms": 1.7402500000007421, + "maxMs": 3.7253749999999854 + }, + "openFence": { + "samples": 5110, + "meanMs": 0.8463017614481487, + "p50Ms": 0.8186249999998836, + "p95Ms": 1.7403749999994034, + "maxMs": 3.7253749999999854 + }, + "lastTenPercentOpenFence": { + "samples": 520, + "meanMs": 1.646597844230767, + "p50Ms": 1.6052500000005239, + "p95Ms": 2.194792000000234, + "maxMs": 3.7253749999999854 + }, + "finalUpdate": { + "samples": 10, + "meanMs": 1.7058375000000523, + "p50Ms": 1.6742920000006052, + "p95Ms": 2.0839579999992566, + "maxMs": 2.0839579999992566 + } + } + }, + { + "group": "streaming", + "name": "marked progressive parse+render", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "iterations": 10, + "msPerOp": 10.240053999999873, + "outputBytes": 18638, + "heapDeltaKb": -4537.4609375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 512, + "openFenceUpdatesPerReplay": 511, + "checksum": 57554796, + "latency": { + "all": { + "samples": 5120, + "meanMs": 0.019879748437497825, + "p50Ms": 0.019417000001340057, + "p95Ms": 0.035875000001396984, + "maxMs": 0.2828749999989668 + }, + "openFence": { + "samples": 5110, + "meanMs": 0.019912887475535518, + "p50Ms": 0.019458999999187654, + "p95Ms": 0.035916000000725035, + "maxMs": 0.2828749999989668 + }, + "lastTenPercentOpenFence": { + "samples": 520, + "meanMs": 0.036673030769200594, + "p50Ms": 0.034790999998222105, + "p95Ms": 0.0476249999992433, + "maxMs": 0.16791600000033213 + }, + "finalUpdate": { + "samples": 10, + "meanMs": 0.03875830000015412, + "p50Ms": 0.03600000000005821, + "p95Ms": 0.05379200000061246, + "maxMs": 0.05379200000061246 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming parse", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 88.78324159999974, + "outputBytes": 0, + "heapDeltaKb": 58977.828125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 28672, + "latency": { + "all": { + "samples": 10240, + "meanMs": 0.04318977167969536, + "p50Ms": 0.036208999999871594, + "p95Ms": 0.08095899999898393, + "maxMs": 3.2906660000007832 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.04320940097704749, + "p50Ms": 0.03620900000169058, + "p95Ms": 0.08100000000013097, + "maxMs": 3.2906660000007832 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.0844143346341493, + "p50Ms": 0.0648750000000291, + "p95Ms": 0.11779199999909906, + "maxMs": 2.9725839999991877 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.0742999999998574, + "p50Ms": 0.06708299999991141, + "p95Ms": 0.08816699999988487, + "maxMs": 0.08816699999988487 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming profile", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 166.69010019999988, + "outputBytes": 74347, + "heapDeltaKb": -65495.1875, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 534043559, + "latency": { + "all": { + "samples": 10240, + "meanMs": 0.08118271386719282, + "p50Ms": 0.07345799999893643, + "p95Ms": 0.1531250000007276, + "maxMs": 2.1279169999997976 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.08121982081094817, + "p50Ms": 0.073500000000422, + "p95Ms": 0.15333299999838346, + "maxMs": 2.1279169999997976 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.15453332097562408, + "p50Ms": 0.1279580000009446, + "p95Ms": 0.22691699999995762, + "maxMs": 1.78583400000025 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.1325332000003982, + "p50Ms": 0.13154100000065228, + "p95Ms": 0.14270900000155962, + "maxMs": 0.14270900000155962 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming with @tanstack/highlight", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 6953.515150000001, + "outputBytes": 249146, + "heapDeltaKb": -35723.3515625, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 1793925812, + "latency": { + "all": { + "samples": 10240, + "meanMs": 3.392740948242209, + "p50Ms": 3.4198749999995925, + "p95Ms": 6.580291999998735, + "maxMs": 12.043541999999434 + }, + "openFence": { + "samples": 10235, + "meanMs": 3.394368667415751, + "p50Ms": 3.421582999995735, + "p95Ms": 6.583459000001312, + "maxMs": 12.043541999999434 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 6.55173894926834, + "p50Ms": 6.430832999998529, + "p95Ms": 7.7552080000023125, + "maxMs": 10.843958000004932 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 6.907216400001198, + "p50Ms": 6.77204100000381, + "p95Ms": 7.664291000000958, + "maxMs": 7.664291000000958 + } + } + }, + { + "group": "streaming", + "name": "marked progressive parse+render", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 148.42968340000078, + "outputBytes": 74318, + "heapDeltaKb": 29715.09375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 533627815, + "latency": { + "all": { + "samples": 10240, + "meanMs": 0.07235159365235333, + "p50Ms": 0.07262500000069849, + "p95Ms": 0.134249999995518, + "maxMs": 0.4335419999988517 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.07238514342941847, + "p50Ms": 0.07266700000036508, + "p95Ms": 0.134249999995518, + "maxMs": 0.4335419999988517 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.135444688780415, + "p50Ms": 0.1322920000020531, + "p95Ms": 0.15491599999950267, + "maxMs": 0.3325830000030692 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.14232500000071013, + "p50Ms": 0.13716700000077253, + "p95Ms": 0.16033299999980954, + "maxMs": 0.16033299999980954 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming parse", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 79.82769179999741, + "outputBytes": 0, + "heapDeltaKb": 71313.5703125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 28672, + "latency": { + "all": { + "samples": 10240, + "meanMs": 0.03892056455080208, + "p50Ms": 0.033999999999650754, + "p95Ms": 0.06441699999413686, + "maxMs": 2.7499169999937294 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.038938336394745345, + "p50Ms": 0.034040999998978805, + "p95Ms": 0.06445800000074087, + "maxMs": 2.7499169999937294 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.07508403219514748, + "p50Ms": 0.06274999999732245, + "p95Ms": 0.08191700000315905, + "maxMs": 2.7499169999937294 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.06999179999838816, + "p50Ms": 0.06583299999329029, + "p95Ms": 0.08716699999786215, + "maxMs": 0.08716699999786215 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming profile", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 151.58304159999972, + "outputBytes": 74347, + "heapDeltaKb": -86751.4609375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 534043559, + "latency": { + "all": { + "samples": 10240, + "meanMs": 0.07392367548829241, + "p50Ms": 0.06974999999511056, + "p95Ms": 0.12745900000300026, + "maxMs": 1.701708000000508 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.0739573055202851, + "p50Ms": 0.0697920000020531, + "p95Ms": 0.12749999999505235, + "maxMs": 1.701708000000508 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.13837541560961852, + "p50Ms": 0.12449999999807915, + "p95Ms": 0.1492080000025453, + "maxMs": 1.62275000000227 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.1289249999987078, + "p50Ms": 0.1275420000019949, + "p95Ms": 0.13320799999928568, + "maxMs": 0.13320799999928568 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming with @tanstack/highlight", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 8161.249008199998, + "outputBytes": 249146, + "heapDeltaKb": -109512.4609375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 1793925812, + "latency": { + "all": { + "samples": 10240, + "meanMs": 3.982018991699145, + "p50Ms": 3.78270799999882, + "p95Ms": 8.104082999998354, + "maxMs": 92.9486670000042 + }, + "openFence": { + "samples": 10235, + "meanMs": 3.9839234586223, + "p50Ms": 3.784333000003244, + "p95Ms": 8.106708999999682, + "maxMs": 92.9486670000042 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 7.017661139512117, + "p50Ms": 6.327290999994148, + "p95Ms": 9.367958000002545, + "maxMs": 89.78366599998844 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 7.071666600002208, + "p50Ms": 6.715166000009049, + "p95Ms": 8.302792000002228, + "maxMs": 8.302792000002228 + } + } + }, + { + "group": "streaming", + "name": "marked progressive parse+render", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "iterations": 5, + "msPerOp": 147.22933320000303, + "outputBytes": 74318, + "heapDeltaKb": 29615.7109375, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2048, + "openFenceUpdatesPerReplay": 2047, + "checksum": 533627815, + "latency": { + "all": { + "samples": 10240, + "meanMs": 0.0717972814452807, + "p50Ms": 0.07083399999828544, + "p95Ms": 0.13324999999895226, + "maxMs": 0.3760000000038417 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.07182997430382528, + "p50Ms": 0.07087499999033753, + "p95Ms": 0.13324999999895226, + "maxMs": 0.3760000000038417 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.13354970634142618, + "p50Ms": 0.13033300000824966, + "p95Ms": 0.1582909999997355, + "maxMs": 0.3410839999996824 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.15028300000121816, + "p50Ms": 0.13937499999883585, + "p95Ms": 0.19883300000219606, + "maxMs": 0.19883300000219606 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming parse", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "iterations": 5, + "msPerOp": 83.45165839999973, + "outputBytes": 0, + "heapDeltaKb": 72340.4921875, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2049, + "openFenceUpdatesPerReplay": 2047, + "checksum": 28693, + "latency": { + "all": { + "samples": 10245, + "meanMs": 0.04065191215222499, + "p50Ms": 0.034916999997221865, + "p95Ms": 0.0695830000040587, + "maxMs": 3.1708749999961583 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.04065160058618007, + "p50Ms": 0.034916999997221865, + "p95Ms": 0.06949999999778811, + "maxMs": 3.1708749999961583 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.07932173170739922, + "p50Ms": 0.06325000000651926, + "p95Ms": 0.09729199999128468, + "maxMs": 2.7610419999982696 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.0778249999973923, + "p50Ms": 0.07470899999316316, + "p95Ms": 0.08883300000161398, + "maxMs": 0.08883300000161398 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming profile", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "iterations": 5, + "msPerOp": 158.23815839999878, + "outputBytes": 74378, + "heapDeltaKb": 3098.046875, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2049, + "openFenceUpdatesPerReplay": 2047, + "checksum": 534564205, + "latency": { + "all": { + "samples": 10245, + "meanMs": 0.07712069389948589, + "p50Ms": 0.06920800000079907, + "p95Ms": 0.1396670000103768, + "maxMs": 1.764999999999418 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.07711955378605276, + "p50Ms": 0.06920800000079907, + "p95Ms": 0.13950000000477303, + "maxMs": 1.764999999999418 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.1427728409754828, + "p50Ms": 0.12816699998802505, + "p95Ms": 0.1669170000095619, + "maxMs": 1.6622079999942798 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.15054999999701976, + "p50Ms": 0.146666999993613, + "p95Ms": 0.16754100000252947, + "maxMs": 0.16754100000252947 + } + } + }, + { + "group": "streaming", + "name": "@tanstack/markdown streaming with @tanstack/highlight", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "iterations": 5, + "msPerOp": 6534.3609, + "outputBytes": 249177, + "heapDeltaKb": -117985.0078125, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2049, + "openFenceUpdatesPerReplay": 2047, + "checksum": 1795670051, + "latency": { + "all": { + "samples": 10245, + "meanMs": 3.187517166325207, + "p50Ms": 3.1581670000159647, + "p95Ms": 6.161041999992449, + "maxMs": 12.539707999996608 + }, + "openFence": { + "samples": 10235, + "meanMs": 3.1875759316074004, + "p50Ms": 3.1581670000159647, + "p95Ms": 6.159833000012441, + "maxMs": 12.539707999996608 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 6.201656636098093, + "p50Ms": 6.096417000022484, + "p95Ms": 7.122999999992317, + "maxMs": 12.539707999996608 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 6.199541799991858, + "p50Ms": 6.250833999976749, + "p95Ms": 6.409249999996973, + "maxMs": 6.409249999996973 + } + } + }, + { + "group": "streaming", + "name": "marked progressive parse+render", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "iterations": 5, + "msPerOp": 146.64199140000272, + "outputBytes": 74349, + "heapDeltaKb": -43318, + "chunkSize": 32, + "warmupReplays": 2, + "updatesPerReplay": 2049, + "openFenceUpdatesPerReplay": 2047, + "checksum": 534148258, + "latency": { + "all": { + "samples": 10245, + "meanMs": 0.07149037306003303, + "p50Ms": 0.07129200000781566, + "p95Ms": 0.13333399998373352, + "maxMs": 0.3815420000173617 + }, + "openFence": { + "samples": 10235, + "meanMs": 0.07148188373229648, + "p50Ms": 0.07129200000781566, + "p95Ms": 0.13329199998406693, + "maxMs": 0.3815420000173617 + }, + "lastTenPercentOpenFence": { + "samples": 1025, + "meanMs": 0.1348610351218206, + "p50Ms": 0.13129200000548735, + "p95Ms": 0.1559170000255108, + "maxMs": 0.3815420000173617 + }, + "finalUpdate": { + "samples": 5, + "meanMs": 0.15603319999645465, + "p50Ms": 0.15587499999674037, + "p95Ms": 0.1638329999987036, + "maxMs": 0.1638329999987036 + } + } } ] } \ No newline at end of file diff --git a/reports/benchmarks.md b/reports/benchmarks.md index adb64ed..29b74d4 100644 --- a/reports/benchmarks.md +++ b/reports/benchmarks.md @@ -1,101 +1,160 @@ # Benchmark Results -Generated: 2026-09-11T20:23:30.359Z +Generated: 2026-09-12T23:59:58.625Z -Lower `ms/op` is better. Benchmarks run in Node with production package builds where available; heap delta is a coarse process-level signal, not an allocation profiler. Streaming rows replay the complete response in 32-character chunks, so one operation is one progressive response. +Environment: Node v24.15.0, darwin arm64, Apple M5 Pro. + +Lower `ms/op` is better. Benchmarks run in Node with production dependency builds where available and local Markdown source; heap delta is a coarse process-level signal, not an allocation profiler. Streaming rows replay the complete response in 32-character chunks, so one operation is one progressive response. Every prefix is parsed from scratch. Replay time includes slicing, timing, and collecting samples; per-update latency times only the parser or renderer call. Browser layout, framework updates, and network delays are excluded. ## Markdown | Name | Fixture | Bytes | Iterations | ms/op | Output bytes | Heap delta KB | | :--- | :--- | ---: | ---: | ---: | ---: | ---: | -| @tanstack/markdown parse | ai-response.md | 652 | 2000 | 0.0184 | 15 | 899.0 | -| @tanstack/markdown render AST with external highlighter | ai-response.md | 652 | 2000 | 0.0053 | 1399 | 1238.7 | -| @tanstack/markdown render AST | ai-response.md | 652 | 2000 | 0.0040 | 1143 | -3027.4 | -| @tanstack/markdown parse+render with external highlighter | ai-response.md | 652 | 2000 | 0.0238 | 1399 | -2596.2 | -| @tanstack/markdown parse+render | ai-response.md | 652 | 2000 | 0.0236 | 1143 | -1860.6 | -| marked parse+render | ai-response.md | 652 | 2000 | 0.0295 | 1035 | -853.6 | -| markdown-it parse+render | ai-response.md | 652 | 2000 | 0.0193 | 1087 | 4088.9 | -| micromark render | ai-response.md | 652 | 2000 | 0.1731 | 825 | -8298.2 | -| commonmark parse+render | ai-response.md | 652 | 2000 | 0.0213 | 825 | 54.9 | -| markdown-wasm render | ai-response.md | 652 | 2000 | 0.0105 | 1117 | 4372.4 | -| unified remark+rehype render | ai-response.md | 652 | 2000 | 0.1991 | 824 | 13756.0 | -| @tanstack/markdown parse | code-heavy.md | 1011 | 1000 | 0.0068 | 15 | -6378.1 | -| @tanstack/markdown render AST with external highlighter | code-heavy.md | 1011 | 1000 | 0.0075 | 4596 | -1122.6 | -| @tanstack/markdown render AST | code-heavy.md | 1011 | 1000 | 0.0032 | 1927 | 10476.3 | -| @tanstack/markdown parse+render with external highlighter | code-heavy.md | 1011 | 1000 | 0.0142 | 4596 | -6339.7 | -| @tanstack/markdown parse+render | code-heavy.md | 1011 | 1000 | 0.0099 | 1927 | 4603.2 | -| marked parse+render | code-heavy.md | 1011 | 1000 | 0.0062 | 1330 | -19757.1 | -| markdown-it parse+render | code-heavy.md | 1011 | 1000 | 0.0083 | 1330 | -5321.0 | -| micromark render | code-heavy.md | 1011 | 1000 | 0.1368 | 1330 | -11833.3 | -| commonmark parse+render | code-heavy.md | 1011 | 1000 | 0.0092 | 1330 | 24577.0 | -| markdown-wasm render | code-heavy.md | 1011 | 1000 | 0.0043 | 1512 | 2560.0 | -| unified remark+rehype render | code-heavy.md | 1011 | 1000 | 0.1521 | 1200 | -7038.0 | -| @tanstack/markdown parse | malformed.md | 237 | 2000 | 0.0027 | 15 | 18580.6 | -| @tanstack/markdown render AST with external highlighter | malformed.md | 237 | 2000 | 0.0017 | 1067 | -17067.3 | -| @tanstack/markdown render AST | malformed.md | 237 | 2000 | 0.0007 | 361 | 5314.9 | -| @tanstack/markdown parse+render with external highlighter | malformed.md | 237 | 2000 | 0.0046 | 1067 | 1452.2 | -| @tanstack/markdown parse+render | malformed.md | 237 | 2000 | 0.0036 | 361 | -8892.2 | -| marked parse+render | malformed.md | 237 | 2000 | 0.0055 | 350 | 16140.5 | -| markdown-it parse+render | malformed.md | 237 | 2000 | 0.0051 | 300 | -7253.6 | -| micromark render | malformed.md | 237 | 2000 | 0.0521 | 300 | 7694.5 | -| commonmark parse+render | malformed.md | 237 | 2000 | 0.0039 | 300 | -9655.9 | -| markdown-wasm render | malformed.md | 237 | 2000 | 0.0019 | 408 | 2938.4 | -| unified remark+rehype render | malformed.md | 237 | 2000 | 0.0692 | 297 | 2219.2 | -| @tanstack/markdown parse | prose-heavy.md | 1700 | 1000 | 0.0172 | 15 | -17554.1 | -| @tanstack/markdown render AST with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0027 | 1903 | 10296.0 | -| @tanstack/markdown render AST | prose-heavy.md | 1700 | 1000 | 0.0027 | 1903 | 10321.3 | -| @tanstack/markdown parse+render with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0177 | 1903 | -7355.0 | -| @tanstack/markdown parse+render | prose-heavy.md | 1700 | 1000 | 0.0166 | 1903 | -7244.9 | -| marked parse+render | prose-heavy.md | 1700 | 1000 | 0.0345 | 1860 | 6729.9 | -| markdown-it parse+render | prose-heavy.md | 1700 | 1000 | 0.0189 | 1860 | -18343.2 | -| micromark render | prose-heavy.md | 1700 | 1000 | 0.2429 | 1862 | 24865.8 | -| commonmark parse+render | prose-heavy.md | 1700 | 1000 | 0.0119 | 1862 | 46458.7 | -| markdown-wasm render | prose-heavy.md | 1700 | 1000 | 0.0063 | 2340 | 3368.4 | -| unified remark+rehype render | prose-heavy.md | 1700 | 1000 | 0.2897 | 1859 | 50897.1 | -| @tanstack/markdown parse | small-doc.md | 432 | 2000 | 0.0094 | 15 | 7577.4 | -| @tanstack/markdown render AST with external highlighter | small-doc.md | 432 | 2000 | 0.0031 | 1370 | 27814.1 | -| @tanstack/markdown render AST | small-doc.md | 432 | 2000 | 0.0027 | 1044 | -42766.9 | -| @tanstack/markdown parse+render with external highlighter | small-doc.md | 432 | 2000 | 0.0126 | 1370 | 31414.9 | -| @tanstack/markdown parse+render | small-doc.md | 432 | 2000 | 0.0121 | 1044 | -40133.3 | -| marked parse+render | small-doc.md | 432 | 2000 | 0.0121 | 724 | 50632.2 | -| markdown-it parse+render | small-doc.md | 432 | 2000 | 0.0078 | 776 | 3160.2 | -| micromark render | small-doc.md | 432 | 2000 | 0.0974 | 535 | 44970.4 | -| commonmark parse+render | small-doc.md | 432 | 2000 | 0.0071 | 535 | -15145.2 | -| markdown-wasm render | small-doc.md | 432 | 2000 | 0.0039 | 854 | 3580.2 | -| unified remark+rehype render | small-doc.md | 432 | 2000 | 0.1293 | 534 | 4179.8 | -| @tanstack/markdown parse | tables-lists.md | 454 | 2000 | 0.0232 | 15 | -43744.4 | -| @tanstack/markdown render AST with external highlighter | tables-lists.md | 454 | 2000 | 0.0044 | 1315 | 42278.0 | -| @tanstack/markdown render AST | tables-lists.md | 454 | 2000 | 0.0040 | 1315 | -23161.2 | -| @tanstack/markdown parse+render with external highlighter | tables-lists.md | 454 | 2000 | 0.0274 | 1315 | -1853.1 | -| @tanstack/markdown parse+render | tables-lists.md | 454 | 2000 | 0.0272 | 1315 | -2112.7 | -| marked parse+render | tables-lists.md | 454 | 2000 | 0.0292 | 1102 | -14329.2 | -| markdown-it parse+render | tables-lists.md | 454 | 2000 | 0.0180 | 1325 | 26395.5 | -| micromark render | tables-lists.md | 454 | 2000 | 0.1677 | 627 | -39153.3 | -| commonmark parse+render | tables-lists.md | 454 | 2000 | 0.0114 | 627 | 28363.8 | -| markdown-wasm render | tables-lists.md | 454 | 2000 | 0.0059 | 1202 | 4506.2 | -| unified remark+rehype render | tables-lists.md | 454 | 2000 | 0.1964 | 622 | 7601.9 | +| @tanstack/markdown parse | ai-response.md | 652 | 2000 | 0.0231 | 15 | 35036.6 | +| @tanstack/markdown render AST with external highlighter | ai-response.md | 652 | 2000 | 0.0050 | 1399 | 10160.7 | +| @tanstack/markdown render AST | ai-response.md | 652 | 2000 | 0.0038 | 1143 | 133.8 | +| @tanstack/markdown parse+render with external highlighter | ai-response.md | 652 | 2000 | 0.0251 | 1399 | 11894.5 | +| @tanstack/markdown parse+render | ai-response.md | 652 | 2000 | 0.0232 | 1143 | 44793.7 | +| marked parse+render | ai-response.md | 652 | 2000 | 0.0293 | 1035 | -92849.1 | +| markdown-it parse+render | ai-response.md | 652 | 2000 | 0.0187 | 1087 | 44907.1 | +| micromark render | ai-response.md | 652 | 2000 | 0.1664 | 825 | 51435.6 | +| commonmark parse+render | ai-response.md | 652 | 2000 | 0.0131 | 825 | -49166.8 | +| markdown-wasm render | ai-response.md | 652 | 2000 | 0.0084 | 1117 | 4376.7 | +| unified remark+rehype render | ai-response.md | 652 | 2000 | 0.2147 | 824 | 7854.6 | +| @tanstack/markdown parse | code-heavy.md | 1011 | 1000 | 0.0093 | 15 | 23903.4 | +| @tanstack/markdown render AST with external highlighter | code-heavy.md | 1011 | 1000 | 0.0088 | 4596 | -30194.4 | +| @tanstack/markdown render AST | code-heavy.md | 1011 | 1000 | 0.0040 | 1927 | 10476.2 | +| @tanstack/markdown parse+render with external highlighter | code-heavy.md | 1011 | 1000 | 0.0193 | 4596 | -5389.8 | +| @tanstack/markdown parse+render | code-heavy.md | 1011 | 1000 | 0.0166 | 1927 | -25821.4 | +| marked parse+render | code-heavy.md | 1011 | 1000 | 0.0073 | 1330 | 12886.8 | +| markdown-it parse+render | code-heavy.md | 1011 | 1000 | 0.0098 | 1330 | 27313.4 | +| micromark render | code-heavy.md | 1011 | 1000 | 0.1617 | 1330 | -5850.4 | +| commonmark parse+render | code-heavy.md | 1011 | 1000 | 0.0109 | 1330 | -40876.0 | +| markdown-wasm render | code-heavy.md | 1011 | 1000 | 0.0054 | 1512 | 2558.3 | +| unified remark+rehype render | code-heavy.md | 1011 | 1000 | 0.1567 | 1200 | 24973.7 | +| @tanstack/markdown parse | malformed.md | 237 | 2000 | 0.0027 | 15 | -44947.9 | +| @tanstack/markdown render AST with external highlighter | malformed.md | 237 | 2000 | 0.0016 | 1067 | 15659.7 | +| @tanstack/markdown render AST | malformed.md | 237 | 2000 | 0.0007 | 361 | 5315.2 | +| @tanstack/markdown parse+render with external highlighter | malformed.md | 237 | 2000 | 0.0046 | 1067 | 33660.8 | +| @tanstack/markdown parse+render | malformed.md | 237 | 2000 | 0.0038 | 361 | -34278.9 | +| marked parse+render | malformed.md | 237 | 2000 | 0.0054 | 350 | 16657.7 | +| markdown-it parse+render | malformed.md | 237 | 2000 | 0.0056 | 300 | -31630.1 | +| micromark render | malformed.md | 237 | 2000 | 0.0504 | 300 | 11184.8 | +| commonmark parse+render | malformed.md | 237 | 2000 | 0.0039 | 300 | 23073.0 | +| markdown-wasm render | malformed.md | 237 | 2000 | 0.0018 | 408 | 2938.7 | +| unified remark+rehype render | malformed.md | 237 | 2000 | 0.0552 | 297 | -30681.6 | +| @tanstack/markdown parse | prose-heavy.md | 1700 | 1000 | 0.0181 | 15 | 1601.4 | +| @tanstack/markdown render AST with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0021 | 1903 | 10292.2 | +| @tanstack/markdown render AST | prose-heavy.md | 1700 | 1000 | 0.0024 | 1903 | 10320.5 | +| @tanstack/markdown parse+render with external highlighter | prose-heavy.md | 1700 | 1000 | 0.0201 | 1903 | 18362.3 | +| @tanstack/markdown parse+render | prose-heavy.md | 1700 | 1000 | 0.0203 | 1903 | 18847.2 | +| marked parse+render | prose-heavy.md | 1700 | 1000 | 0.0381 | 1860 | -5593.0 | +| markdown-it parse+render | prose-heavy.md | 1700 | 1000 | 0.0206 | 1860 | -8952.1 | +| micromark render | prose-heavy.md | 1700 | 1000 | 0.2549 | 1862 | 21780.6 | +| commonmark parse+render | prose-heavy.md | 1700 | 1000 | 0.0136 | 1862 | -14599.4 | +| markdown-wasm render | prose-heavy.md | 1700 | 1000 | 0.0064 | 2340 | 3369.4 | +| unified remark+rehype render | prose-heavy.md | 1700 | 1000 | 0.3225 | 1859 | -73200.9 | +| @tanstack/markdown parse | small-doc.md | 432 | 2000 | 0.0127 | 15 | 20112.5 | +| @tanstack/markdown render AST with external highlighter | small-doc.md | 432 | 2000 | 0.0043 | 1370 | -24522.9 | +| @tanstack/markdown render AST | small-doc.md | 432 | 2000 | 0.0028 | 1044 | 21772.9 | +| @tanstack/markdown parse+render with external highlighter | small-doc.md | 432 | 2000 | 0.0164 | 1370 | -9773.9 | +| @tanstack/markdown parse+render | small-doc.md | 432 | 2000 | 0.0168 | 1044 | -7187.6 | +| marked parse+render | small-doc.md | 432 | 2000 | 0.0147 | 724 | 50669.2 | +| markdown-it parse+render | small-doc.md | 432 | 2000 | 0.0087 | 776 | 4037.9 | +| micromark render | small-doc.md | 432 | 2000 | 0.1330 | 535 | 44880.4 | +| commonmark parse+render | small-doc.md | 432 | 2000 | 0.0074 | 535 | -15136.9 | +| markdown-wasm render | small-doc.md | 432 | 2000 | 0.0037 | 854 | 3631.2 | +| unified remark+rehype render | small-doc.md | 432 | 2000 | 0.1475 | 534 | 4013.4 | +| @tanstack/markdown parse | tables-lists.md | 454 | 2000 | 0.0295 | 15 | 12749.1 | +| @tanstack/markdown render AST with external highlighter | tables-lists.md | 454 | 2000 | 0.0058 | 1315 | -5483.2 | +| @tanstack/markdown render AST | tables-lists.md | 454 | 2000 | 0.0052 | 1315 | -14400.9 | +| @tanstack/markdown parse+render with external highlighter | tables-lists.md | 454 | 2000 | 0.0366 | 1315 | 6365.8 | +| @tanstack/markdown parse+render | tables-lists.md | 454 | 2000 | 0.0383 | 1315 | -91885.8 | +| marked parse+render | tables-lists.md | 454 | 2000 | 0.0325 | 1102 | -2774.7 | +| markdown-it parse+render | tables-lists.md | 454 | 2000 | 0.0213 | 1325 | 26335.4 | +| micromark render | tables-lists.md | 454 | 2000 | 0.1830 | 627 | 43377.3 | +| commonmark parse+render | tables-lists.md | 454 | 2000 | 0.0117 | 627 | -36650.8 | +| markdown-wasm render | tables-lists.md | 454 | 2000 | 0.0058 | 1202 | 4506.1 | +| unified remark+rehype render | tables-lists.md | 454 | 2000 | 0.2090 | 622 | -44098.0 | ## Streaming | Name | Fixture | Bytes | Iterations | ms/op | Output bytes | Heap delta KB | | :--- | :--- | ---: | ---: | ---: | ---: | ---: | -| @tanstack/markdown streaming profile | ai-response.md | 652 | 250 | 0.2236 | 1112 | -8191.5 | -| marked progressive parse+render | ai-response.md | 652 | 250 | 0.2804 | 1035 | 3232.0 | +| @tanstack/markdown streaming parse | ai-response.md | 652 | 250 | 0.2141 | 0 | 27889.1 | +| @tanstack/markdown streaming profile | ai-response.md | 652 | 250 | 0.2579 | 1112 | 65860.8 | +| @tanstack/markdown streaming with @tanstack/highlight | ai-response.md | 652 | 250 | 0.3811 | 1274 | 26043.9 | +| marked progressive parse+render | ai-response.md | 652 | 250 | 0.3046 | 1035 | 19526.3 | +| @tanstack/markdown streaming parse | unfinished-backtick-4kib | 4096 | 20 | 0.4621 | 0 | -34834.7 | +| @tanstack/markdown streaming profile | unfinished-backtick-4kib | 4096 | 20 | 0.8601 | 4731 | 695.4 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-backtick-4kib | 4096 | 20 | 22.0687 | 16010 | 62990.4 | +| marked progressive parse+render | unfinished-backtick-4kib | 4096 | 20 | 0.7149 | 4702 | -36811.4 | +| @tanstack/markdown streaming parse | unfinished-backtick-16kib | 16384 | 10 | 5.8481 | 0 | -165018.2 | +| @tanstack/markdown streaming profile | unfinished-backtick-16kib | 16384 | 10 | 10.3301 | 18667 | 66138.5 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-backtick-16kib | 16384 | 10 | 433.2552 | 62868 | 35834.8 | +| marked progressive parse+render | unfinished-backtick-16kib | 16384 | 10 | 10.2401 | 18638 | -4537.5 | +| @tanstack/markdown streaming parse | unfinished-backtick-64kib | 65536 | 5 | 88.7832 | 0 | 58977.8 | +| @tanstack/markdown streaming profile | unfinished-backtick-64kib | 65536 | 5 | 166.6901 | 74347 | -65495.2 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-backtick-64kib | 65536 | 5 | 6953.5152 | 249146 | -35723.4 | +| marked progressive parse+render | unfinished-backtick-64kib | 65536 | 5 | 148.4297 | 74318 | 29715.1 | +| @tanstack/markdown streaming parse | unfinished-tilde-64kib | 65536 | 5 | 79.8277 | 0 | 71313.6 | +| @tanstack/markdown streaming profile | unfinished-tilde-64kib | 65536 | 5 | 151.5830 | 74347 | -86751.5 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-tilde-64kib | 65536 | 5 | 8161.2490 | 249146 | -109512.5 | +| marked progressive parse+render | unfinished-tilde-64kib | 65536 | 5 | 147.2293 | 74318 | 29615.7 | +| @tanstack/markdown streaming parse | closing-backtick-64kib | 65566 | 5 | 83.4517 | 0 | 72340.5 | +| @tanstack/markdown streaming profile | closing-backtick-64kib | 65566 | 5 | 158.2382 | 74378 | 3098.0 | +| @tanstack/markdown streaming with @tanstack/highlight | closing-backtick-64kib | 65566 | 5 | 6534.3609 | 249177 | -117985.0 | +| marked progressive parse+render | closing-backtick-64kib | 65566 | 5 | 146.6420 | 74349 | -43318.0 | + +For persistent React and incremental DOM comparisons against streaming-focused libraries, see the [browser streaming report](./streaming-browser.md). + +## Streaming update latency + +All timings below are milliseconds, pooled across measured replays after two full warmup replays. Percentiles use nearest rank. Each update contributes its output to a checksum. Parse-only rows use the same streaming options as rendering rows and report zero HTML output bytes. + +Generated fixtures contain a growing TypeScript fence with an unfinished final line. Backtick fences cover 4, 16, and 64 KiB; a 64 KiB tilde fence checks the other delimiter. The closing case appends a closing fence and trailing prose to the same 64 KiB body. Open-fence membership uses known fixture offsets, independently of the parser. The late-open sample covers prefixes in the last 10% of the source up to the closing fence, or EOF when it never closes. + +The streaming highlighter is the installed @tanstack/highlight TypeScript tokenizer and HTML adapter, initialized before timing and called on every code block on every update. The non-streaming external-highlighter rows above use a line-wrapping stub. Parse-only and plain-render rows help compare parsing cost with rendering; these are separate runs, not an instrumented phase breakdown. + +| Name | Fixture | Updates/replay | Open updates/replay | Update p50 | Update p95 | Open p50 | Open p95 | Open max | Late open p95 | Final p50 | +| :--- | :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| @tanstack/markdown streaming parse | ai-response.md | 21 | 2 | 0.0075 | 0.0159 | 0.0058 | 0.0071 | 0.0494 | 0.0070 | 0.0149 | +| @tanstack/markdown streaming profile | ai-response.md | 21 | 2 | 0.0089 | 0.0193 | 0.0072 | 0.0087 | 0.0530 | 0.0091 | 0.0185 | +| @tanstack/markdown streaming with @tanstack/highlight | ai-response.md | 21 | 2 | 0.0176 | 0.0295 | 0.0126 | 0.0174 | 1.8180 | 0.0201 | 0.0261 | +| marked progressive parse+render | ai-response.md | 21 | 2 | 0.0131 | 0.0254 | 0.0098 | 0.0133 | 0.0253 | 0.0138 | 0.0235 | +| @tanstack/markdown streaming parse | unfinished-backtick-4kib | 128 | 127 | 0.0031 | 0.0051 | 0.0031 | 0.0051 | 0.6202 | 0.0061 | 0.0050 | +| @tanstack/markdown streaming profile | unfinished-backtick-4kib | 128 | 127 | 0.0056 | 0.0097 | 0.0057 | 0.0097 | 2.0297 | 0.0134 | 0.0095 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-backtick-4kib | 128 | 127 | 0.1685 | 0.3212 | 0.1694 | 0.3213 | 0.7369 | 0.3593 | 0.3322 | +| marked progressive parse+render | unfinished-backtick-4kib | 128 | 127 | 0.0053 | 0.0093 | 0.0054 | 0.0093 | 0.3233 | 0.0112 | 0.0095 | +| @tanstack/markdown streaming parse | unfinished-backtick-16kib | 512 | 511 | 0.0095 | 0.0181 | 0.0095 | 0.0181 | 2.3500 | 0.0242 | 0.0176 | +| @tanstack/markdown streaming profile | unfinished-backtick-16kib | 512 | 511 | 0.0189 | 0.0345 | 0.0189 | 0.0345 | 1.5925 | 0.0410 | 0.0361 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-backtick-16kib | 512 | 511 | 0.8176 | 1.7403 | 0.8186 | 1.7404 | 3.7254 | 2.1948 | 1.6743 | +| marked progressive parse+render | unfinished-backtick-16kib | 512 | 511 | 0.0194 | 0.0359 | 0.0195 | 0.0359 | 0.2829 | 0.0476 | 0.0360 | +| @tanstack/markdown streaming parse | unfinished-backtick-64kib | 2048 | 2047 | 0.0362 | 0.0810 | 0.0362 | 0.0810 | 3.2907 | 0.1178 | 0.0671 | +| @tanstack/markdown streaming profile | unfinished-backtick-64kib | 2048 | 2047 | 0.0735 | 0.1531 | 0.0735 | 0.1533 | 2.1279 | 0.2269 | 0.1315 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-backtick-64kib | 2048 | 2047 | 3.4199 | 6.5803 | 3.4216 | 6.5835 | 12.0435 | 7.7552 | 6.7720 | +| marked progressive parse+render | unfinished-backtick-64kib | 2048 | 2047 | 0.0726 | 0.1342 | 0.0727 | 0.1342 | 0.4335 | 0.1549 | 0.1372 | +| @tanstack/markdown streaming parse | unfinished-tilde-64kib | 2048 | 2047 | 0.0340 | 0.0644 | 0.0340 | 0.0645 | 2.7499 | 0.0819 | 0.0658 | +| @tanstack/markdown streaming profile | unfinished-tilde-64kib | 2048 | 2047 | 0.0697 | 0.1275 | 0.0698 | 0.1275 | 1.7017 | 0.1492 | 0.1275 | +| @tanstack/markdown streaming with @tanstack/highlight | unfinished-tilde-64kib | 2048 | 2047 | 3.7827 | 8.1041 | 3.7843 | 8.1067 | 92.9487 | 9.3680 | 6.7152 | +| marked progressive parse+render | unfinished-tilde-64kib | 2048 | 2047 | 0.0708 | 0.1332 | 0.0709 | 0.1332 | 0.3760 | 0.1583 | 0.1394 | +| @tanstack/markdown streaming parse | closing-backtick-64kib | 2049 | 2047 | 0.0349 | 0.0696 | 0.0349 | 0.0695 | 3.1709 | 0.0973 | 0.0747 | +| @tanstack/markdown streaming profile | closing-backtick-64kib | 2049 | 2047 | 0.0692 | 0.1397 | 0.0692 | 0.1395 | 1.7650 | 0.1669 | 0.1467 | +| @tanstack/markdown streaming with @tanstack/highlight | closing-backtick-64kib | 2049 | 2047 | 3.1582 | 6.1610 | 3.1582 | 6.1598 | 12.5397 | 7.1230 | 6.2508 | +| marked progressive parse+render | closing-backtick-64kib | 2049 | 2047 | 0.0713 | 0.1333 | 0.0713 | 0.1333 | 0.3815 | 0.1559 | 0.1559 | ## Averages | Group | Name | Mean ms/op | | :--- | :--- | ---: | -| markdown | @tanstack/markdown parse | 0.0129 | -| markdown | @tanstack/markdown render AST with external highlighter | 0.0041 | -| markdown | @tanstack/markdown render AST | 0.0029 | -| markdown | @tanstack/markdown parse+render with external highlighter | 0.0167 | -| markdown | @tanstack/markdown parse+render | 0.0155 | -| markdown | marked parse+render | 0.0195 | -| markdown | markdown-it parse+render | 0.0129 | -| markdown | micromark render | 0.1450 | -| markdown | commonmark parse+render | 0.0108 | -| markdown | markdown-wasm render | 0.0055 | -| markdown | unified remark+rehype render | 0.1726 | -| streaming | @tanstack/markdown streaming profile | 0.2236 | -| streaming | marked progressive parse+render | 0.2804 | +| markdown | @tanstack/markdown parse | 0.0159 | +| markdown | @tanstack/markdown render AST with external highlighter | 0.0046 | +| markdown | @tanstack/markdown render AST | 0.0031 | +| markdown | @tanstack/markdown parse+render with external highlighter | 0.0204 | +| markdown | @tanstack/markdown parse+render | 0.0198 | +| markdown | marked parse+render | 0.0212 | +| markdown | markdown-it parse+render | 0.0141 | +| markdown | micromark render | 0.1583 | +| markdown | commonmark parse+render | 0.0101 | +| markdown | markdown-wasm render | 0.0052 | +| markdown | unified remark+rehype render | 0.1843 | diff --git a/reports/sizes.json b/reports/sizes.json index 5347214..286102b 100644 --- a/reports/sizes.json +++ b/reports/sizes.json @@ -1,40 +1,40 @@ { - "generatedAt": "2026-09-11T20:23:25.725Z", + "generatedAt": "2026-09-12T23:57:10.287Z", "results": [ { "group": "tanstack", "name": "parser only", - "minBytes": 13157, - "gzipBytes": 4975, - "brotliBytes": 4593 + "minBytes": 11949, + "gzipBytes": 4894, + "brotliBytes": 4539 }, { "group": "tanstack", "name": "html renderer no highlighter", - "minBytes": 18337, - "gzipBytes": 6807, - "brotliBytes": 6240 + "minBytes": 17139, + "gzipBytes": 6729, + "brotliBytes": 6158 }, { "group": "tanstack", "name": "html renderer with external highlighter stub", - "minBytes": 18373, - "gzipBytes": 6829, - "brotliBytes": 6260 + "minBytes": 17175, + "gzipBytes": 6751, + "brotliBytes": 6184 }, { "group": "tanstack", "name": "react adapter", - "minBytes": 18271, - "gzipBytes": 6722, - "brotliBytes": 6184 + "minBytes": 17147, + "gzipBytes": 6676, + "brotliBytes": 6170 }, { "group": "tanstack", "name": "octane adapter", - "minBytes": 18283, - "gzipBytes": 6727, - "brotliBytes": 6194 + "minBytes": 17084, + "gzipBytes": 6644, + "brotliBytes": 6143 }, { "group": "tanstack", @@ -60,9 +60,9 @@ { "group": "tanstack", "name": "react adapter with streaming extension", - "minBytes": 18962, - "gzipBytes": 6907, - "brotliBytes": 6356 + "minBytes": 17839, + "gzipBytes": 6860, + "brotliBytes": 6302 }, { "group": "tanstack", @@ -116,37 +116,37 @@ { "group": "tanstack-public", "name": ".", - "minBytes": 18598, - "gzipBytes": 6936, - "brotliBytes": 6345 + "minBytes": 17404, + "gzipBytes": 6852, + "brotliBytes": 6267 }, { "group": "tanstack-public", "name": "./html", - "minBytes": 18560, - "gzipBytes": 6920, - "brotliBytes": 6331 + "minBytes": 17364, + "gzipBytes": 6840, + "brotliBytes": 6283 }, { "group": "tanstack-public", "name": "./parser", - "minBytes": 13289, - "gzipBytes": 5061, - "brotliBytes": 4664 + "minBytes": 12082, + "gzipBytes": 4981, + "brotliBytes": 4599 }, { "group": "tanstack-public", "name": "./react", - "minBytes": 18470, - "gzipBytes": 6828, - "brotliBytes": 6279 + "minBytes": 17347, + "gzipBytes": 6790, + "brotliBytes": 6241 }, { "group": "tanstack-public", "name": "./octane", - "minBytes": 18485, - "gzipBytes": 6833, - "brotliBytes": 6287 + "minBytes": 17287, + "gzipBytes": 6752, + "brotliBytes": 6209 }, { "group": "tanstack-public", diff --git a/reports/sizes.md b/reports/sizes.md index a30d23f..c639246 100644 --- a/reports/sizes.md +++ b/reports/sizes.md @@ -1,20 +1,20 @@ # Bundle Size Results -Generated: 2026-09-11T20:23:25.726Z +Generated: 2026-09-12T23:57:10.289Z Bundles are ESM, browser-targeted, minified with esbuild, then gzip and brotli compressed. Framework runtimes are externalized for the React and Octane adapters. | Group | Entry | Min bytes | Gzip bytes | Brotli bytes | | :--- | :--- | ---: | ---: | ---: | -| tanstack | parser only | 13157 | 4975 | 4593 | -| tanstack | html renderer no highlighter | 18337 | 6807 | 6240 | -| tanstack | html renderer with external highlighter stub | 18373 | 6829 | 6260 | -| tanstack | react adapter | 18271 | 6722 | 6184 | -| tanstack | octane adapter | 18283 | 6727 | 6194 | +| tanstack | parser only | 11949 | 4894 | 4539 | +| tanstack | html renderer no highlighter | 17139 | 6729 | 6158 | +| tanstack | html renderer with external highlighter stub | 17175 | 6751 | 6184 | +| tanstack | react adapter | 17147 | 6676 | 6170 | +| tanstack | octane adapter | 17084 | 6644 | 6143 | | tanstack | docs extension preset | 6423 | 2292 | 2073 | | tanstack | callouts extension | 506 | 335 | 278 | | tanstack | streaming extension | 699 | 311 | 253 | -| tanstack | react adapter with streaming extension | 18962 | 6907 | 6356 | +| tanstack | react adapter with streaming extension | 17839 | 6860 | 6302 | | tanstack | tabs transforms | 3290 | 1221 | 1082 | | markdown | marked | 41415 | 12548 | 11509 | | markdown | markdown-it | 148242 | 52655 | 44023 | @@ -22,11 +22,11 @@ Bundles are ESM, browser-targeted, minified with esbuild, then gzip and brotli c | markdown | commonmark | 159687 | 48084 | 39793 | | markdown | markdown-wasm browser js+wasm | 66387 | 31275 | 26431 | | markdown | unified remark+rehype | 119588 | 36843 | 32686 | -| tanstack-public | . | 18598 | 6936 | 6345 | -| tanstack-public | ./html | 18560 | 6920 | 6331 | -| tanstack-public | ./parser | 13289 | 5061 | 4664 | -| tanstack-public | ./react | 18470 | 6828 | 6279 | -| tanstack-public | ./octane | 18485 | 6833 | 6287 | +| tanstack-public | . | 17404 | 6852 | 6267 | +| tanstack-public | ./html | 17364 | 6840 | 6283 | +| tanstack-public | ./parser | 12082 | 4981 | 4599 | +| tanstack-public | ./react | 17347 | 6790 | 6241 | +| tanstack-public | ./octane | 17287 | 6752 | 6209 | | tanstack-public | ./extensions/callouts | 660 | 432 | 360 | | tanstack-public | ./extensions/comment-components | 1073 | 647 | 542 | | tanstack-public | ./extensions/docs | 6587 | 2392 | 2162 | diff --git a/reports/streaming-browser.json b/reports/streaming-browser.json new file mode 100644 index 0000000..6dfad0b --- /dev/null +++ b/reports/streaming-browser.json @@ -0,0 +1,1381 @@ +{ + "generatedAt": "2026-09-12T17:00:04.103Z", + "environment": { + "node": "v24.15.0", + "browser": "152.0.7977.83", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M5 Pro", + "isolated": true, + "dependencies": { + "react": "19.2.7", + "react-dom": "19.2.7", + "streamdown": "2.6.0", + "streaming-markdown": "0.2.15" + } + }, + "results": [ + { + "name": "TanStack Markdown React", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 127, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 0, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 4.528999972343445, + "p50Ms": 4.605000019073486, + "p95Ms": 4.684999942779541, + "maxMs": 4.684999942779541 + }, + "finish": { + "samples": 5, + "meanMs": 0.0009999990463256836, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.004999995231628418 + }, + "updates": { + "update": { + "samples": 640, + "meanMs": 0.00989062488079071, + "p50Ms": 0.009999990463256836, + "p95Ms": 0.015000104904174805, + "maxMs": 0.2850000858306885 + }, + "updateAndLayout": { + "samples": 640, + "meanMs": 0.034890624694526196, + "p50Ms": 0.034999966621398926, + "p95Ms": 0.05500006675720215, + "maxMs": 0.3250000476837158 + } + }, + "openFence": { + "update": { + "samples": 635, + "meanMs": 0.009677165324293722, + "p50Ms": 0.009999990463256836, + "p95Ms": 0.015000104904174805, + "maxMs": 0.2850000858306885 + }, + "updateAndLayout": { + "samples": 635, + "meanMs": 0.0346377952830998, + "p50Ms": 0.034999966621398926, + "p95Ms": 0.05500006675720215, + "maxMs": 0.3250000476837158 + } + }, + "lateOpenFence": { + "update": { + "samples": 65, + "meanMs": 0.01269231392787053, + "p50Ms": 0.009999990463256836, + "p95Ms": 0.019999980926513672, + "maxMs": 0.08000004291534424 + }, + "updateAndLayout": { + "samples": 65, + "meanMs": 0.0514615352337177, + "p50Ms": 0.04999995231628418, + "p95Ms": 0.06499993801116943, + "maxMs": 0.11500000953674316 + } + }, + "checksum": 775341 + }, + { + "name": "Streamdown React", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 127, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 1, + "finalCodeMatches": true, + "finalTextDiffers": true + }, + "replay": { + "samples": 5, + "meanMs": 30.30799994468689, + "p50Ms": 29.75499999523163, + "p95Ms": 32.359999895095825, + "maxMs": 32.359999895095825 + }, + "finish": { + "samples": 5, + "meanMs": 0.23099994659423828, + "p50Ms": 0.2049999237060547, + "p95Ms": 0.34999990463256836, + "maxMs": 0.34999990463256836 + }, + "updates": { + "update": { + "samples": 640, + "meanMs": 0.14968750011175871, + "p50Ms": 0.14499998092651367, + "p95Ms": 0.2549999952316284, + "maxMs": 1.1800000667572021 + }, + "updateAndLayout": { + "samples": 640, + "meanMs": 0.2342031253501773, + "p50Ms": 0.23500001430511475, + "p95Ms": 0.39499998092651367, + "maxMs": 1.2850000858306885 + } + }, + "openFence": { + "update": { + "samples": 635, + "meanMs": 0.14910236268531618, + "p50Ms": 0.14499998092651367, + "p95Ms": 0.2549999952316284, + "maxMs": 1.1800000667572021 + }, + "updateAndLayout": { + "samples": 635, + "meanMs": 0.23391338614966925, + "p50Ms": 0.23500001430511475, + "p95Ms": 0.39499998092651367, + "maxMs": 1.2850000858306885 + } + }, + "lateOpenFence": { + "update": { + "samples": 65, + "meanMs": 0.2404615328862117, + "p50Ms": 0.22499990463256836, + "p95Ms": 0.3799999952316284, + "maxMs": 0.44499993324279785 + }, + "updateAndLayout": { + "samples": 65, + "meanMs": 0.392692310993488, + "p50Ms": 0.375, + "p95Ms": 0.5399999618530273, + "maxMs": 0.6200000047683716 + } + }, + "checksum": 775341 + }, + { + "name": "streaming-markdown DOM", + "fixture": "unfinished-backtick-4kib", + "bytes": 4096, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 127, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 2, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 3.7110000133514403, + "p50Ms": 3.069999933242798, + "p95Ms": 4.810000061988831, + "maxMs": 4.810000061988831 + }, + "finish": { + "samples": 5, + "meanMs": 0.05500004291534424, + "p50Ms": 0.040000081062316895, + "p95Ms": 0.08500003814697266, + "maxMs": 0.08500003814697266 + }, + "updates": { + "update": { + "samples": 640, + "meanMs": 0.00123437512665987, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.029999971389770508 + }, + "updateAndLayout": { + "samples": 640, + "meanMs": 0.028304686956107616, + "p50Ms": 0.02499997615814209, + "p95Ms": 0.060000061988830566, + "maxMs": 0.38999998569488525 + } + }, + "openFence": { + "update": { + "samples": 635, + "meanMs": 0.0011653543457271545, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.029999971389770508 + }, + "updateAndLayout": { + "samples": 635, + "meanMs": 0.028330707925511157, + "p50Ms": 0.02499997615814209, + "p95Ms": 0.060000061988830566, + "maxMs": 0.38999998569488525 + } + }, + "lateOpenFence": { + "update": { + "samples": 65, + "meanMs": 0.0016153885768010066, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.005000114440917969 + }, + "updateAndLayout": { + "samples": 65, + "meanMs": 0.04623076548943153, + "p50Ms": 0.039999961853027344, + "p95Ms": 0.0700000524520874, + "maxMs": 0.0849999189376831 + } + }, + "checksum": 774753 + }, + { + "name": "TanStack Markdown React", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 511, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 0, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 47.487000012397765, + "p50Ms": 46.18999993801117, + "p95Ms": 53.3050000667572, + "maxMs": 53.3050000667572 + }, + "finish": { + "samples": 5, + "meanMs": 0, + "p50Ms": 0, + "p95Ms": 0, + "maxMs": 0 + }, + "updates": { + "update": { + "samples": 2560, + "meanMs": 0.016054687881842257, + "p50Ms": 0.014999985694885254, + "p95Ms": 0.029999971389770508, + "maxMs": 0.09500002861022949 + }, + "updateAndLayout": { + "samples": 2560, + "meanMs": 0.08960742205381393, + "p50Ms": 0.08000004291534424, + "p95Ms": 0.1549999713897705, + "maxMs": 5.034999966621399 + } + }, + "openFence": { + "update": { + "samples": 2555, + "meanMs": 0.016013698988464713, + "p50Ms": 0.014999985694885254, + "p95Ms": 0.029999971389770508, + "maxMs": 0.09500002861022949 + }, + "updateAndLayout": { + "samples": 2555, + "meanMs": 0.08963992189754479, + "p50Ms": 0.08000004291534424, + "p95Ms": 0.15500009059906006, + "maxMs": 5.034999966621399 + } + }, + "lateOpenFence": { + "update": { + "samples": 260, + "meanMs": 0.02742307552924523, + "p50Ms": 0.02499997615814209, + "p95Ms": 0.04999995231628418, + "maxMs": 0.09500002861022949 + }, + "updateAndLayout": { + "samples": 260, + "meanMs": 0.18905769128065844, + "p50Ms": 0.1499999761581421, + "p95Ms": 0.23500001430511475, + "maxMs": 5.034999966621399 + } + }, + "checksum": 10925523 + }, + { + "name": "Streamdown React", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 511, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 1, + "finalCodeMatches": true, + "finalTextDiffers": true + }, + "replay": { + "samples": 5, + "meanMs": 366.48600001335143, + "p50Ms": 365.5550000667572, + "p95Ms": 370.585000038147, + "maxMs": 370.585000038147 + }, + "finish": { + "samples": 5, + "meanMs": 0.6489999771118165, + "p50Ms": 0.6499999761581421, + "p95Ms": 0.6649999618530273, + "maxMs": 0.6649999618530273 + }, + "updates": { + "update": { + "samples": 2560, + "meanMs": 0.411314452951774, + "p50Ms": 0.3950001001358032, + "p95Ms": 0.7450000047683716, + "maxMs": 1.9800000190734863 + }, + "updateAndLayout": { + "samples": 2560, + "meanMs": 0.7138535155914724, + "p50Ms": 0.6950000524520874, + "p95Ms": 1.3200000524520874, + "maxMs": 2.534999966621399 + } + }, + "openFence": { + "update": { + "samples": 2555, + "meanMs": 0.41170254383535065, + "p50Ms": 0.3999999761581421, + "p95Ms": 0.7450000047683716, + "maxMs": 1.9800000190734863 + }, + "updateAndLayout": { + "samples": 2555, + "meanMs": 0.7147240704053068, + "p50Ms": 0.6999999284744263, + "p95Ms": 1.3200000524520874, + "maxMs": 2.534999966621399 + } + }, + "lateOpenFence": { + "update": { + "samples": 260, + "meanMs": 0.751346151645367, + "p50Ms": 0.7300000190734863, + "p95Ms": 0.875, + "maxMs": 1.9449999332427979 + }, + "updateAndLayout": { + "samples": 260, + "meanMs": 1.3240192303290734, + "p50Ms": 1.3049999475479126, + "p95Ms": 1.4500000476837158, + "maxMs": 2.534999966621399 + } + }, + "checksum": 10925530 + }, + { + "name": "streaming-markdown DOM", + "fixture": "unfinished-backtick-16kib", + "bytes": 16384, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 511, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 2, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 50.31999998092651, + "p50Ms": 50.63499999046326, + "p95Ms": 52.35000002384186, + "maxMs": 52.35000002384186 + }, + "finish": { + "samples": 5, + "meanMs": 0.17100000381469727, + "p50Ms": 0.1650000810623169, + "p95Ms": 0.19999992847442627, + "maxMs": 0.19999992847442627 + }, + "updates": { + "update": { + "samples": 2560, + "meanMs": 0.005187500081956386, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.1150000095367432 + }, + "updateAndLayout": { + "samples": 2560, + "meanMs": 0.09778906241990626, + "p50Ms": 0.08000004291534424, + "p95Ms": 0.1549999713897705, + "maxMs": 3.640000104904175 + } + }, + "openFence": { + "update": { + "samples": 2555, + "meanMs": 0.0051565558943029955, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.1150000095367432 + }, + "updateAndLayout": { + "samples": 2555, + "meanMs": 0.09788258318331844, + "p50Ms": 0.08000004291534424, + "p95Ms": 0.1549999713897705, + "maxMs": 3.640000104904175 + } + }, + "lateOpenFence": { + "update": { + "samples": 260, + "meanMs": 0.011942307765667255, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.1150000095367432 + }, + "updateAndLayout": { + "samples": 260, + "meanMs": 0.18648076974428618, + "p50Ms": 0.14500010013580322, + "p95Ms": 0.19000005722045898, + "maxMs": 2.509999990463257 + } + }, + "checksum": 10924641 + }, + { + "name": "TanStack Markdown React", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 0, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 677.7850000143051, + "p50Ms": 678.7400000095367, + "p95Ms": 680.4900000095367, + "maxMs": 680.4900000095367 + }, + "finish": { + "samples": 5, + "meanMs": 0, + "p50Ms": 0, + "p95Ms": 0, + "maxMs": 0 + }, + "updates": { + "update": { + "samples": 10240, + "meanMs": 0.056908202916383745, + "p50Ms": 0.04500007629394531, + "p95Ms": 0.09000003337860107, + "maxMs": 1.409999966621399 + }, + "updateAndLayout": { + "samples": 10240, + "meanMs": 0.33075488277245313, + "p50Ms": 0.3049999475479126, + "p95Ms": 0.5699999332427979, + "maxMs": 3.8550000190734863 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 0.05691499733377446, + "p50Ms": 0.04500007629394531, + "p95Ms": 0.09000003337860107, + "maxMs": 1.409999966621399 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 0.33087445036865293, + "p50Ms": 0.3049999475479126, + "p95Ms": 0.5699999332427979, + "maxMs": 3.8550000190734863 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 0.10408292619193472, + "p50Ms": 0.0849999189376831, + "p95Ms": 0.11500000953674316, + "maxMs": 1.2799999713897705 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 0.6159707312467622, + "p50Ms": 0.5499999523162842, + "p95Ms": 0.6950000524520874, + "maxMs": 3.375 + } + }, + "checksum": 167105001 + }, + { + "name": "Streamdown React", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 1, + "finalCodeMatches": true, + "finalTextDiffers": true + }, + "replay": { + "samples": 5, + "meanMs": 5932.687999987602, + "p50Ms": 5922.759999990463, + "p95Ms": 5979.625, + "maxMs": 5979.625 + }, + "finish": { + "samples": 5, + "meanMs": 3.165000009536743, + "p50Ms": 3.0449999570846558, + "p95Ms": 3.9149999618530273, + "maxMs": 3.9149999618530273 + }, + "updates": { + "update": { + "samples": 10240, + "meanMs": 1.6887353521306068, + "p50Ms": 1.6299999952316284, + "p95Ms": 3.3799999952316284, + "maxMs": 6.870000004768372 + }, + "updateAndLayout": { + "samples": 10240, + "meanMs": 2.8947646484710274, + "p50Ms": 2.8499999046325684, + "p95Ms": 5.684999942779541, + "maxMs": 9.205000042915344 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 1.6894215931587238, + "p50Ms": 1.6299999952316284, + "p95Ms": 3.3799999952316284, + "maxMs": 6.870000004768372 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 2.8960068393231135, + "p50Ms": 2.850000023841858, + "p95Ms": 5.684999942779541, + "maxMs": 9.205000042915344 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 3.4095658515139324, + "p50Ms": 3.305000066757202, + "p95Ms": 4.049999952316284, + "maxMs": 6.870000004768372 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 5.706985365588491, + "p50Ms": 5.580000042915344, + "p95Ms": 6.784999966621399, + "maxMs": 9.205000042915344 + } + }, + "checksum": 167105008 + }, + { + "name": "streaming-markdown DOM", + "fixture": "unfinished-backtick-64kib", + "bytes": 65536, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 2, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 788.8239999771118, + "p50Ms": 788.9249999523163, + "p95Ms": 795.0750000476837, + "maxMs": 795.0750000476837 + }, + "finish": { + "samples": 5, + "meanMs": 0.7149999856948852, + "p50Ms": 0.7149999141693115, + "p95Ms": 0.7999999523162842, + "maxMs": 0.7999999523162842 + }, + "updates": { + "update": { + "samples": 10240, + "meanMs": 0.013867675850633532, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.0399999618530273 + }, + "updateAndLayout": { + "samples": 10240, + "meanMs": 0.38456298826495183, + "p50Ms": 0.3250000476837158, + "p95Ms": 0.6499999761581421, + "maxMs": 4.790000081062317 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 0.013867122694871807, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.0399999618530273 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 0.3847288715123082, + "p50Ms": 0.3250000476837158, + "p95Ms": 0.6499999761581421, + "maxMs": 4.790000081062317 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 0.02483414591812506, + "p50Ms": 0, + "p95Ms": 0.009999990463256836, + "maxMs": 1.0199999809265137 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 0.7283317070472531, + "p50Ms": 0.6000000238418579, + "p95Ms": 1.4950000047683716, + "maxMs": 4.350000023841858 + } + }, + "checksum": 167103237 + }, + { + "name": "TanStack Markdown React", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 0, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 677.8530000209809, + "p50Ms": 678.5, + "p95Ms": 680.9800000190735, + "maxMs": 680.9800000190735 + }, + "finish": { + "samples": 5, + "meanMs": 0.0009999990463256836, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.004999995231628418 + }, + "updates": { + "update": { + "samples": 10240, + "meanMs": 0.05543408194789663, + "p50Ms": 0.04500007629394531, + "p95Ms": 0.09000003337860107, + "maxMs": 2.225000023841858 + }, + "updateAndLayout": { + "samples": 10240, + "meanMs": 0.33078222658950834, + "p50Ms": 0.30500006675720215, + "p95Ms": 0.5750000476837158, + "maxMs": 3.4850000143051147 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 0.055436248073475386, + "p50Ms": 0.04500007629394531, + "p95Ms": 0.09000003337860107, + "maxMs": 2.225000023841858 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 0.330896433831193, + "p50Ms": 0.30500006675720215, + "p95Ms": 0.5750000476837158, + "maxMs": 3.4850000143051147 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 0.10096097376288438, + "p50Ms": 0.0849999189376831, + "p95Ms": 0.11000001430511475, + "maxMs": 1.190000057220459 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 0.6201219512195122, + "p50Ms": 0.5549999475479126, + "p95Ms": 0.7400000095367432, + "maxMs": 3.4850000143051147 + } + }, + "checksum": 167105001 + }, + { + "name": "Streamdown React", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": false, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 2047, + "maxBufferedCharacters": 65503, + "finalCodeMatches": false, + "firstMismatch": { + "end": 64, + "expectedTail": "const route0: Route = { path: '", + "actualTail": "const route0: Route = { path: '~~" + }, + "finalTextDiffers": true, + "finalMismatch": { + "expectedTail": "onst route1091: Route = { path: '/api/1091', enabled: true }\nconst route1092: Ro", + "actualTail": "t route1091: Route = { path: '/api/1091', enabled: true }\nconst route1092: Ro~~\n" + } + }, + "replay": null, + "finish": null, + "updates": { + "update": null, + "updateAndLayout": null + }, + "openFence": { + "update": null, + "updateAndLayout": null + }, + "lateOpenFence": { + "update": null, + "updateAndLayout": null + }, + "checksum": 0 + }, + { + "name": "streaming-markdown DOM", + "fixture": "unfinished-tilde-64kib", + "bytes": 65536, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": false, + "openUpdates": 2047, + "missingFenceUpdates": 2047, + "mismatchedCodeUpdates": 2047, + "maxBufferedCharacters": 65503, + "finalCodeMatches": false, + "firstMismatch": { + "end": 64, + "expectedTail": "const route0: Route = { path: '", + "actualTail": "" + }, + "finalTextDiffers": true, + "finalMismatch": { + "expectedTail": "onst route1091: Route = { path: '/api/1091', enabled: true }\nconst route1092: Ro", + "actualTail": "" + } + }, + "replay": null, + "finish": null, + "updates": { + "update": null, + "updateAndLayout": null + }, + "openFence": { + "update": null, + "updateAndLayout": null + }, + "lateOpenFence": { + "update": null, + "updateAndLayout": null + }, + "checksum": 0 + }, + { + "name": "TanStack Markdown React", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 0, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 678.2109999895096, + "p50Ms": 677.8999999761581, + "p95Ms": 681.4700000286102, + "maxMs": 681.4700000286102 + }, + "finish": { + "samples": 5, + "meanMs": 0, + "p50Ms": 0, + "p95Ms": 0, + "maxMs": 0 + }, + "updates": { + "update": { + "samples": 10245, + "meanMs": 0.04983748220245451, + "p50Ms": 0.04999995231628418, + "p95Ms": 0.09000003337860107, + "maxMs": 1.065000057220459 + }, + "updateAndLayout": { + "samples": 10245, + "meanMs": 0.3243035626795421, + "p50Ms": 0.3049999475479126, + "p95Ms": 0.5700000524520874, + "maxMs": 3.3899999856948853 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 0.04981240894250539, + "p50Ms": 0.04999995231628418, + "p95Ms": 0.09000003337860107, + "maxMs": 1.065000057220459 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 0.324515388369502, + "p50Ms": 0.30500006675720215, + "p95Ms": 0.5700000524520874, + "maxMs": 3.3899999856948853 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 0.09007317054562453, + "p50Ms": 0.0849999189376831, + "p95Ms": 0.11000001430511475, + "maxMs": 0.8899999856948853 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 0.605429268232206, + "p50Ms": 0.5499999523162842, + "p95Ms": 0.6549999713897705, + "maxMs": 3.1649999618530273 + } + }, + "checksum": 167266876 + }, + { + "name": "Streamdown React", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 1, + "finalCodeMatches": true, + "finalTextDiffers": true + }, + "replay": { + "samples": 5, + "meanMs": 6081.446000003814, + "p50Ms": 6058.159999966621, + "p95Ms": 6236.325000047684, + "maxMs": 6236.325000047684 + }, + "finish": { + "samples": 5, + "meanMs": 0.020999979972839356, + "p50Ms": 0.019999980926513672, + "p95Ms": 0.029999971389770508, + "maxMs": 0.029999971389770508 + }, + "updates": { + "update": { + "samples": 10245, + "meanMs": 1.7450082967315086, + "p50Ms": 1.6649999618530273, + "p95Ms": 3.4499999284744263, + "maxMs": 6.400000095367432 + }, + "updateAndLayout": { + "samples": 10245, + "meanMs": 2.963551488371812, + "p50Ms": 2.875, + "p95Ms": 5.754999995231628, + "maxMs": 8.805000066757202 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 1.7447493893260424, + "p50Ms": 1.6649999618530273, + "p95Ms": 3.444999933242798, + "maxMs": 6.400000095367432 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 2.9644298972417276, + "p50Ms": 2.875, + "p95Ms": 5.754999995231628, + "maxMs": 8.805000066757202 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 3.4744829260430685, + "p50Ms": 3.375, + "p95Ms": 4.315000057220459, + "maxMs": 6.400000095367432 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 5.782839024125076, + "p50Ms": 5.65500009059906, + "p95Ms": 7.059999942779541, + "maxMs": 8.805000066757202 + } + }, + "checksum": 167266883 + }, + { + "name": "streaming-markdown DOM", + "fixture": "closing-backtick-64kib", + "bytes": 65566, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 2047, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 2, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 842.5479999780655, + "p50Ms": 848.7749999761581, + "p95Ms": 862.9599999189377, + "maxMs": 862.9599999189377 + }, + "finish": { + "samples": 5, + "meanMs": 0.0019999980926513673, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.004999995231628418 + }, + "updates": { + "update": { + "samples": 10245, + "meanMs": 0.026625182746153685, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 2.90500009059906 + }, + "updateAndLayout": { + "samples": 10245, + "meanMs": 0.41093557831599925, + "p50Ms": 0.33000004291534424, + "p95Ms": 0.6549999713897705, + "maxMs": 6.445000052452087 + } + }, + "openFence": { + "update": { + "samples": 10235, + "meanMs": 0.02663800656999328, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 2.90500009059906 + }, + "updateAndLayout": { + "samples": 10235, + "meanMs": 0.41095945285364777, + "p50Ms": 0.33000004291534424, + "p95Ms": 0.6500000953674316, + "maxMs": 6.445000052452087 + } + }, + "lateOpenFence": { + "update": { + "samples": 1025, + "meanMs": 0.048058535529346004, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 2.8550000190734863 + }, + "updateAndLayout": { + "samples": 1025, + "meanMs": 0.7703024389685654, + "p50Ms": 0.6050000190734863, + "p95Ms": 1.0399999618530273, + "maxMs": 6.445000052452087 + } + }, + "checksum": 167265112 + }, + { + "name": "TanStack Markdown React", + "fixture": "settled-prose-16kib-then-open-fence-16kib", + "bytes": 32770, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 512, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 0, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 327.65800004005433, + "p50Ms": 325.99000000953674, + "p95Ms": 334.3550000190735, + "maxMs": 334.3550000190735 + }, + "finish": { + "samples": 5, + "meanMs": 0, + "p50Ms": 0, + "p95Ms": 0, + "maxMs": 0 + }, + "updates": { + "update": { + "samples": 5125, + "meanMs": 0.24721658415910674, + "p50Ms": 0.2900000810623169, + "p95Ms": 0.3450000286102295, + "maxMs": 2.125 + }, + "updateAndLayout": { + "samples": 5125, + "meanMs": 0.3195121950986909, + "p50Ms": 0.3549998998641968, + "p95Ms": 0.4950000047683716, + "maxMs": 2.75 + } + }, + "openFence": { + "update": { + "samples": 2560, + "meanMs": 0.32893945183604956, + "p50Ms": 0.31499993801116943, + "p95Ms": 0.35500001907348633, + "maxMs": 2.125 + }, + "updateAndLayout": { + "samples": 2560, + "meanMs": 0.44414648436941206, + "p50Ms": 0.4249999523162842, + "p95Ms": 0.5150001049041748, + "maxMs": 2.75 + } + }, + "lateOpenFence": { + "update": { + "samples": 520, + "meanMs": 0.3400384604930878, + "p50Ms": 0.3200000524520874, + "p95Ms": 0.3700000047683716, + "maxMs": 2.125 + }, + "updateAndLayout": { + "samples": 520, + "meanMs": 0.513423076730508, + "p50Ms": 0.48500001430511475, + "p95Ms": 0.5600000619888306, + "maxMs": 2.75 + } + }, + "checksum": 89206215 + }, + { + "name": "Streamdown React", + "fixture": "settled-prose-16kib-then-open-fence-16kib", + "bytes": 32770, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 512, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 1, + "finalCodeMatches": true, + "finalTextDiffers": true + }, + "replay": { + "samples": 5, + "meanMs": 1196.574999976158, + "p50Ms": 1197.9749999046326, + "p95Ms": 1212.2549999952316, + "maxMs": 1212.2549999952316 + }, + "finish": { + "samples": 5, + "meanMs": 1.4390000104904175, + "p50Ms": 1.3650000095367432, + "p95Ms": 1.7450000047683716, + "maxMs": 1.7450000047683716 + }, + "updates": { + "update": { + "samples": 5125, + "meanMs": 0.9679200010299682, + "p50Ms": 0.9249999523162842, + "p95Ms": 1.8849999904632568, + "maxMs": 4.504999995231628 + }, + "updateAndLayout": { + "samples": 5125, + "meanMs": 1.1657765852532735, + "p50Ms": 1, + "p95Ms": 2.5, + "maxMs": 5.195000052452087 + } + }, + "openFence": { + "update": { + "samples": 2560, + "meanMs": 1.4375019538681955, + "p50Ms": 1.415000081062317, + "p95Ms": 2.0149999856948853, + "maxMs": 4.504999995231628 + }, + "updateAndLayout": { + "samples": 2560, + "meanMs": 1.7968203122727573, + "p50Ms": 1.7699999809265137, + "p95Ms": 2.6549999713897705, + "maxMs": 5.195000052452087 + } + }, + "lateOpenFence": { + "update": { + "samples": 520, + "meanMs": 1.9037115360681827, + "p50Ms": 1.8600000143051147, + "p95Ms": 2.174999952316284, + "maxMs": 4.504999995231628 + }, + "updateAndLayout": { + "samples": 520, + "meanMs": 2.5160384613734026, + "p50Ms": 2.465000033378601, + "p95Ms": 2.865000009536743, + "maxMs": 5.195000052452087 + } + }, + "checksum": 89206222 + }, + { + "name": "streaming-markdown DOM", + "fixture": "settled-prose-16kib-then-open-fence-16kib", + "bytes": 32770, + "chunkSize": 32, + "iterations": 5, + "warmupReplays": 2, + "validation": { + "passed": true, + "openUpdates": 512, + "missingFenceUpdates": 0, + "mismatchedCodeUpdates": 0, + "maxBufferedCharacters": 2, + "finalCodeMatches": true, + "finalTextDiffers": false + }, + "replay": { + "samples": 5, + "meanMs": 80.59500000476837, + "p50Ms": 81.6450001001358, + "p95Ms": 82.55000007152557, + "maxMs": 82.55000007152557 + }, + "finish": { + "samples": 5, + "meanMs": 0.1989999771118164, + "p50Ms": 0.1950000524520874, + "p95Ms": 0.2200000286102295, + "maxMs": 0.2200000286102295 + }, + "updates": { + "update": { + "samples": 5125, + "meanMs": 0.003562926618064322, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.1399999856948853 + }, + "updateAndLayout": { + "samples": 5125, + "meanMs": 0.07830536579504245, + "p50Ms": 0.04499995708465576, + "p95Ms": 0.1799999475479126, + "maxMs": 3.4250000715255737 + } + }, + "openFence": { + "update": { + "samples": 2560, + "meanMs": 0.0048828123603016135, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 1.1399999856948853 + }, + "updateAndLayout": { + "samples": 2560, + "meanMs": 0.13017187495715915, + "p50Ms": 0.11500000953674316, + "p95Ms": 0.19000005722045898, + "maxMs": 3.4250000715255737 + } + }, + "lateOpenFence": { + "update": { + "samples": 520, + "meanMs": 0.0073557681762255155, + "p50Ms": 0, + "p95Ms": 0.004999995231628418, + "maxMs": 0.8499999046325684 + }, + "updateAndLayout": { + "samples": 520, + "meanMs": 0.20069230726132026, + "p50Ms": 0.17499995231628418, + "p95Ms": 0.2200000286102295, + "maxMs": 2.5799999237060547 + } + }, + "checksum": 89198662 + } + ] +} diff --git a/reports/streaming-browser.md b/reports/streaming-browser.md new file mode 100644 index 0000000..2115b7f --- /dev/null +++ b/reports/streaming-browser.md @@ -0,0 +1,45 @@ +# Streaming library comparison + +Generated: 2026-09-12T17:00:04.103Z + +Chrome 152.0.7977.83, Apple M5 Pro, darwin arm64. Production React 19.2.7, Streamdown 2.6.0, streaming-markdown 0.2.15. Cross-origin isolation: true. + +Each response arrives in 32-character chunks. TanStack Markdown and Streamdown keep a React root mounted across updates and commit each update with flushSync. Streamdown uses streaming mode with incomplete-Markdown repair and block memoization enabled. streaming-markdown keeps one incremental parser and receives only new chunks through its default DOM renderer. Each replay starts with a fresh root or parser. + +Code is plain text for every library. Streamdown uses custom pre/code components, with animations, controls, and line numbers disabled. Its default parsing, GFM, and sanitization remain enabled. No syntax highlighter is installed in this browser harness. This isolates streaming rendering from highlighter choices and is not a comparison of default product interfaces or syntax coverage. + +Update latency includes parsing, rendering, and synchronous DOM commit. Update + layout also forces style and layout with offsetHeight after every chunk. Paint, network delays, animation frames, and bundle loading are excluded. All libraries use the same 800px-wide container and basic typography. Browser timer resolution limits very small measurements. + +Five measured replays follow two full warmups. Percentiles use nearest rank over pooled updates. Late-open samples cover the last 10% of source before the closing fence or EOF. Replay totals include update sampling, forced layout, and stream finalization, but exclude root/parser creation, cleanup, precomputed prefix/chunk slicing, and validation. End-of-stream finalization is also recorded separately in JSON. + +Before timing, a separate replay checks the code block at every open-fence update, ignoring serializer-added trailing newlines. Up to three trailing characters may be buffered for delimiter recognition, and the observed maximum is reported. Final code must match after trimming trailing whitespace; exact-text differences are recorded separately in JSON. Cases that fail this content check receive no timing. This validates the code-fence workload, not full Markdown conformance. + +| Renderer | Fixture | Replay mean ms | Open update p95 ms | Open update + layout p95 ms | Late open update + layout p95 ms | Max update + layout ms | Buffered chars | +| :--- | :--- | ---: | ---: | ---: | ---: | ---: | ---: | +| TanStack Markdown React | unfinished-backtick-4kib | 4.529 | 0.015 | 0.055 | 0.065 | 0.325 | 0 | +| Streamdown React | unfinished-backtick-4kib | 30.308 | 0.255 | 0.395 | 0.540 | 1.285 | 1 | +| streaming-markdown DOM | unfinished-backtick-4kib | 3.711 | 0.005 | 0.060 | 0.070 | 0.390 | 2 | +| TanStack Markdown React | unfinished-backtick-16kib | 47.487 | 0.030 | 0.155 | 0.235 | 5.035 | 0 | +| Streamdown React | unfinished-backtick-16kib | 366.486 | 0.745 | 1.320 | 1.450 | 2.535 | 1 | +| streaming-markdown DOM | unfinished-backtick-16kib | 50.320 | 0.005 | 0.155 | 0.190 | 3.640 | 2 | +| TanStack Markdown React | unfinished-backtick-64kib | 677.785 | 0.090 | 0.570 | 0.695 | 3.855 | 0 | +| Streamdown React | unfinished-backtick-64kib | 5932.688 | 3.380 | 5.685 | 6.785 | 9.205 | 1 | +| streaming-markdown DOM | unfinished-backtick-64kib | 788.824 | 0.005 | 0.650 | 1.495 | 4.790 | 2 | +| TanStack Markdown React | unfinished-tilde-64kib | 677.853 | 0.090 | 0.575 | 0.740 | 3.485 | 0 | +| Streamdown React | unfinished-tilde-64kib | n/a | n/a | n/a | n/a | n/a | failed content check | +| streaming-markdown DOM | unfinished-tilde-64kib | n/a | n/a | n/a | n/a | n/a | failed content check | +| TanStack Markdown React | closing-backtick-64kib | 678.211 | 0.090 | 0.570 | 0.655 | 3.390 | 0 | +| Streamdown React | closing-backtick-64kib | 6081.446 | 3.445 | 5.755 | 7.060 | 8.805 | 1 | +| streaming-markdown DOM | closing-backtick-64kib | 842.548 | 0.005 | 0.650 | 1.040 | 6.445 | 2 | +| TanStack Markdown React | settled-prose-16kib-then-open-fence-16kib | 327.658 | 0.355 | 0.515 | 0.560 | 2.750 | 0 | +| Streamdown React | settled-prose-16kib-then-open-fence-16kib | 1196.575 | 2.015 | 2.655 | 2.865 | 5.195 | 1 | +| streaming-markdown DOM | settled-prose-16kib-then-open-fence-16kib | 80.595 | 0.005 | 0.190 | 0.220 | 3.425 | 2 | + +Streamdown React, unfinished-tilde-64kib: 0 open updates missing a code fence; 2047 updates with incorrect or buffered code beyond the allowed three characters; final code match: false. +streaming-markdown DOM, unfinished-tilde-64kib: 2047 open updates missing a code fence; 2047 updates with incorrect or buffered code beyond the allowed three characters; final code match: false. + +The settled-prose case adds 16 KiB of completed prose before a growing 16 KiB fence, so retained blocks can benefit from memoization. The other cases contain one heading and one growing fence. Results apply to these fixtures and configurations. + +Reproduce with `pnpm exec playwright install chromium` followed by `pnpm run bench:streaming`. To use an installed Chrome instead, run `BENCH_BROWSER_CHANNEL=chrome pnpm run bench:streaming`. `--smoke` runs the smallest case and validates the harness without replacing reports. + +Library APIs: [Streamdown](https://streamdown.ai/docs/components), [streaming-markdown](https://github.com/thetarnav/streaming-markdown). diff --git a/reports/streaming-optimization.md b/reports/streaming-optimization.md new file mode 100644 index 0000000..10eacb4 --- /dev/null +++ b/reports/streaming-optimization.md @@ -0,0 +1,30 @@ +# Streaming optimization results + +Plain code, 32-character updates, persistent renderer state, production Chrome on Apple M5 Pro. Replay times include synchronous DOM updates and forced layout, but exclude paint and syntax highlighting. Before and after are separate runs on the same machine. See the [browser report](./streaming-browser.md) for configurations, validation, and per-update latency. + +| Fixture | TanStack before, ms | TanStack after, ms | streaming-markdown, ms | Streamdown, ms | +| :--- | ---: | ---: | ---: | ---: | +| unfinished-backtick-4kib | 11.59 | 4.53 | 3.71 | 30.31 | +| unfinished-backtick-16kib | 165.01 | 47.49 | 50.32 | 366.49 | +| unfinished-backtick-64kib | 2615.27 | 677.79 | 788.82 | 5932.69 | +| unfinished-tilde-64kib | 2601.32 | 677.85 | content check failed | content check failed | +| closing-backtick-64kib | 2717.50 | 678.21 | 842.55 | 6081.45 | +| settled-prose-16kib-then-open-fence-16kib | 443.06 | 327.66 | 80.60 | 1196.57 | + +The 64 KiB unfinished and closing backtick cases now have the lowest total replay time among the tested renderers. The 16 KiB result is close to streaming-markdown. TanStack still trails streaming-markdown on the 4 KiB and mixed-prose cases, so this does not establish a win across the full suite. Both competitors fail the tilde-fence content check. + +React streaming now retains completed groups of plain code lines in stable text nodes. Static rendering and custom code components retain string children. Parser state uses local variables, and block detection skips irrelevant checks on ordinary text and fenced code. Parsing still processes the full accumulated source on every update. + +| Entry | Minified before → after | Gzip before → after | Brotli before → after | +| :--- | ---: | ---: | ---: | +| parser only | 13157 → 11949 | 4975 → 4894 | 4593 → 4539 | +| html renderer no highlighter | 18337 → 17139 | 6807 → 6729 | 6240 → 6158 | +| react adapter | 18271 → 17147 | 6722 → 6676 | 6184 → 6170 | +| octane adapter | 18283 → 17084 | 6727 → 6644 | 6194 → 6143 | +| react adapter with streaming extension | 18962 → 17839 | 6907 → 6860 | 6356 → 6302 | + +Every measured public entry remains at or below its original minified, gzip, and Brotli size. Size-test ceilings were lowered to the new measurements. + +Validation: 255 tests, 24 browser checks covering hydration, updates, Unicode, overrides, and highlighting transitions, typechecking, a production build, and docs verification passed. The before/after CommonMark comparison retained all 403 previously passing examples. + +Reproduce the browser regression checks with `BENCH_BROWSER_CHANNEL=chrome pnpm run test:streaming-browser`, and the comparison with `BENCH_BROWSER_CHANNEL=chrome pnpm run bench:streaming`. Browser fixtures and competitor configurations are unchanged by the optimization. diff --git a/scripts/bench-streaming-browser.ts b/scripts/bench-streaming-browser.ts new file mode 100644 index 0000000..63a0224 --- /dev/null +++ b/scripts/bench-streaming-browser.ts @@ -0,0 +1,116 @@ +import { createServer } from 'node:http' +import { readFile, writeFile } from 'node:fs/promises' +import { cpus } from 'node:os' +import { build } from 'esbuild' +import { chromium } from 'playwright' +import { createFenceFixture, summarizeLatency } from './streaming-bench.js' +import type { RendererName, runBrowserCase } from './streaming-browser.js' + +const smoke = process.argv.includes('--smoke') +const rendererFilter = process.argv.find(arg => arg.startsWith('--renderer='))?.slice(11) +const fixtureFilter = process.argv.find(arg => arg.startsWith('--fixture='))?.slice(10) +const filtered = rendererFilter !== undefined || fixtureFilter !== undefined +const iterations = smoke ? 1 : 5 +const names: RendererName[] = ['TanStack Markdown React', 'Streamdown React', 'streaming-markdown DOM'] +const fixtures = [ + ...[4, 16, 64].map(kib => createFenceFixture(kib)), + createFenceFixture(64, '~~~'), + createFenceFixture(64, '```', true), +] +const openFence = createFenceFixture(16) +const prose = Array.from({ length: 160 }, (_, index) => + `## Finding ${index}\n\nThe completed response includes **important details** and [a reference](https://example.com/${index}).\n\n`, +).join('').slice(0, 16 * 1024) + '\n\n' +fixtures.push({ + ...openFence, + name: 'settled-prose-16kib-then-open-fence-16kib', + source: prose + openFence.source, + fenceOpenAt: prose.length + openFence.fenceOpenAt!, +}) + +const bundle = await build({ + entryPoints: ['scripts/streaming-browser.tsx'], bundle: true, write: false, + minify: true, platform: 'browser', format: 'iife', target: 'es2022', jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"production"' }, +}) +const html = '
' +const server = createServer((request, response) => { + response.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + response.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + response.setHeader('Content-Type', request.url === '/bench.js' ? 'text/javascript' : 'text/html') + response.end(request.url === '/bench.js' ? bundle.outputFiles[0]!.contents : html) +}) +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) +const address = server.address() +if (!address || typeof address === 'string') throw new Error('Missing benchmark server address') + +let browser +try { + browser = await chromium.launch({ headless: true, ...(process.env.BENCH_BROWSER_CHANNEL ? { channel: process.env.BENCH_BROWSER_CHANNEL } : {}) }) + const page = await browser.newPage({ viewport: { width: 1000, height: 800 }, deviceScaleFactor: 1 }) + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto(`http://127.0.0.1:${address.port}`) + const environment = { + node: process.version, browser: browser.version(), platform: process.platform, arch: process.arch, cpu: cpus()[0]?.model, + isolated: await page.evaluate(() => crossOriginIsolated), + dependencies: Object.fromEntries(await Promise.all(['react', 'react-dom', 'streamdown', 'streaming-markdown'].map(async name => + [name, JSON.parse(await readFile(`node_modules/${name}/package.json`, 'utf8')).version], + ))), + } + const results = [] + const selectedFixtures = fixtures.filter(fixture => !fixtureFilter || fixture.name.includes(fixtureFilter)) + const selectedNames = names.filter(name => !rendererFilter || name.includes(rendererFilter)) + if (!selectedFixtures.length || !selectedNames.length) throw new Error('No benchmark cases match the filters') + for (const fixture of smoke ? selectedFixtures.slice(0, 1) : selectedFixtures) { + for (const name of selectedNames) { + console.log(`Browser streaming: ${fixture.name}, ${name}`) + const raw = await page.evaluate(({ name, fixture, iterations }) => window.runStreamingCase(name, fixture, iterations), { name, fixture, iterations }) as ReturnType + if (errors.length) throw new Error(errors.join('\n')) + const samples = raw.measured?.samples ?? [] + const open = samples.filter(sample => sample.openFence) + const late = open.filter(sample => sample.end >= (fixture.fenceClosedAt ?? fixture.source.length) * 0.9) + const summary = (subset: typeof samples) => ({ + update: summarizeLatency(subset.map(sample => sample.updateMs)), + updateAndLayout: summarizeLatency(subset.map(sample => sample.updateAndLayoutMs)), + }) + const result = { + name, fixture: fixture.name, bytes: Buffer.byteLength(fixture.source), chunkSize: 32, iterations, warmupReplays: 2, + validation: raw.validation, + replay: summarizeLatency(raw.measured?.replayMs ?? []), + finish: summarizeLatency(raw.measured?.finishMs ?? []), + updates: summary(samples), openFence: summary(open), lateOpenFence: summary(late), checksum: raw.measured?.checksum ?? 0, + } + results.push(result) + console.log(JSON.stringify({ valid: result.validation.passed, replayMs: result.replay?.meanMs, openUpdateP95: result.openFence.update?.p95Ms, openLayoutP95: result.openFence.updateAndLayout?.p95Ms, ...(!result.validation.passed ? { validation: result.validation } : {}) })) + } + } + if (smoke || filtered) { + if (results.some(result => !result.validation.passed)) throw new Error('Browser smoke validation failed') + } else { + const generatedAt = new Date().toISOString() + await writeFile('reports/streaming-browser.json', JSON.stringify({ generatedAt, environment, results }, null, 2) + '\n') + const ms = (value: number | undefined) => value === undefined ? 'n/a' : value.toFixed(3) + const report = [ + '# Streaming library comparison', '', + `Generated: ${generatedAt}`, '', + `Chrome ${environment.browser}, ${environment.cpu}, ${environment.platform} ${environment.arch}. Production React ${environment.dependencies.react}, Streamdown ${environment.dependencies.streamdown}, streaming-markdown ${environment.dependencies['streaming-markdown']}. Cross-origin isolation: ${environment.isolated}.`, '', + 'Each response arrives in 32-character chunks. TanStack Markdown and Streamdown keep a React root mounted across updates and commit each update with flushSync. Streamdown uses streaming mode with incomplete-Markdown repair and block memoization enabled. streaming-markdown keeps one incremental parser and receives only new chunks through its default DOM renderer. Each replay starts with a fresh root or parser.', '', + 'Code is plain text for every library. Streamdown uses custom pre/code components, with animations, controls, and line numbers disabled. Its default parsing, GFM, and sanitization remain enabled. No syntax highlighter is installed in this browser harness. This isolates streaming rendering from highlighter choices and is not a comparison of default product interfaces or syntax coverage.', '', + 'Update latency includes parsing, rendering, and synchronous DOM commit. Update + layout also forces style and layout with offsetHeight after every chunk. Paint, network delays, animation frames, and bundle loading are excluded. All libraries use the same 800px-wide container and basic typography. Browser timer resolution limits very small measurements.', '', + 'Five measured replays follow two full warmups. Percentiles use nearest rank over pooled updates. Late-open samples cover the last 10% of source before the closing fence or EOF. Replay totals include update sampling, forced layout, and stream finalization, but exclude root/parser creation, cleanup, precomputed prefix/chunk slicing, and validation. End-of-stream finalization is also recorded separately in JSON.', '', + 'Before timing, a separate replay checks the code block at every open-fence update, ignoring serializer-added trailing newlines. Up to three trailing characters may be buffered for delimiter recognition, and the observed maximum is reported. Final code must match after trimming trailing whitespace; exact-text differences are recorded separately in JSON. Cases that fail this content check receive no timing. This validates the code-fence workload, not full Markdown conformance.', '', + '| Renderer | Fixture | Replay mean ms | Open update p95 ms | Open update + layout p95 ms | Late open update + layout p95 ms | Max update + layout ms | Buffered chars |', + '| :--- | :--- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...results.map(result => `| ${result.name} | ${result.fixture} | ${ms(result.replay?.meanMs)} | ${ms(result.openFence.update?.p95Ms)} | ${ms(result.openFence.updateAndLayout?.p95Ms)} | ${ms(result.lateOpenFence.updateAndLayout?.p95Ms)} | ${ms(result.updates.updateAndLayout?.maxMs)} | ${result.validation.passed ? result.validation.maxBufferedCharacters : 'failed content check'} |`), '', + ...results.filter(result => !result.validation.passed).map(result => `${result.name}, ${result.fixture}: ${result.validation.missingFenceUpdates} open updates missing a code fence; ${result.validation.mismatchedCodeUpdates} updates with incorrect or buffered code beyond the allowed three characters; final code match: ${result.validation.finalCodeMatches}.`), '', + 'The settled-prose case adds 16 KiB of completed prose before a growing 16 KiB fence, so retained blocks can benefit from memoization. The other cases contain one heading and one growing fence. Results apply to these fixtures and configurations.', '', + 'Reproduce with `pnpm exec playwright install chromium` followed by `pnpm run bench:streaming`. To use an installed Chrome instead, run `BENCH_BROWSER_CHANNEL=chrome pnpm run bench:streaming`. `--smoke` runs the smallest case and validates the harness without replacing reports.', '', + 'Library APIs: [Streamdown](https://streamdown.ai/docs/components), [streaming-markdown](https://github.com/thetarnav/streaming-markdown).', '', + ].join('\n') + await writeFile('reports/streaming-browser.md', report) + } +} finally { + await browser?.close() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) +} diff --git a/scripts/bench.ts b/scripts/bench.ts index 92084ba..22f395f 100644 --- a/scripts/bench.ts +++ b/scripts/bench.ts @@ -2,6 +2,11 @@ import { performance } from 'node:perf_hooks' import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { cpus } from 'node:os' +import { createHighlighter } from '@tanstack/highlight/core' +import { plaintext } from '@tanstack/highlight/languages/plaintext' +import { ts } from '@tanstack/highlight/languages/ts' +import { createTanStackMarkdownHighlighter } from '@tanstack/highlight/markdown' import * as commonmark from 'commonmark' import { parse as parseWasm, ready as wasmReady } from 'markdown-wasm' import MarkdownIt from 'markdown-it' @@ -13,6 +18,7 @@ import remarkRehype from 'remark-rehype' import { unified } from 'unified' import { streamingMarkdownExtension } from '../src/extensions/streaming.js' import { parseMarkdown, renderHtml } from '../src/index.js' +import { benchStream, createAiResponseFixture, createFenceFixture } from './streaming-bench.js' const root = process.cwd() const fixtureDir = join(root, 'fixtures', 'benchmark') @@ -92,26 +98,34 @@ async function main() { } } - const streamingFixture = fixtures.find(fixture => fixture.name === 'ai-response.md') - if (streamingFixture) { - const streamingExtensions = [streamingMarkdownExtension()] - const streamingRenderers = [ - { - name: '@tanstack/markdown streaming profile', - run: (source: string) => replayStream(source, prefix => renderHtml(prefix, { extensions: streamingExtensions, frontmatter: false, headingIds: false })), - }, - { - name: 'marked progressive parse+render', - run: (source: string) => replayStream(source, prefix => String(marked.parse(prefix))), - }, - ] - + const aiResponse = fixtures.find(fixture => fixture.name === 'ai-response.md') + const streamingFixtures = [ + ...(aiResponse ? [createAiResponseFixture(aiResponse.source)] : []), + ...[4, 16, 64].map(kib => createFenceFixture(kib)), + createFenceFixture(64, '~~~'), + createFenceFixture(64, '```', true), + ] + const streamingOptions = { extensions: [streamingMarkdownExtension()], frontmatter: false, headingIds: false } + const highlighter = createTanStackMarkdownHighlighter(createHighlighter({ languages: [plaintext, ts] })) + const highlightedOptions = { ...streamingOptions, highlighter } + const streamingRenderers = [ + { name: '@tanstack/markdown streaming parse', run: (source: string) => parseMarkdown(source, streamingOptions) }, + { name: '@tanstack/markdown streaming profile', run: (source: string) => renderHtml(source, streamingOptions) }, + { name: '@tanstack/markdown streaming with @tanstack/highlight', run: (source: string) => renderHtml(source, highlightedOptions) }, + { name: 'marked progressive parse+render', run: (source: string) => String(marked.parse(source)) }, + ] + for (const fixture of streamingFixtures) { + const iterations = fixture.source.length < 1000 ? 250 : fixture.source.length <= 4096 ? 20 : fixture.source.length <= 16384 ? 10 : 5 for (const renderer of streamingRenderers) { - results.push(bench('streaming', renderer.name, streamingFixture.name, streamingFixture.source, 250, renderer.run)) + console.log(`Streaming: ${fixture.name}, ${renderer.name} (${iterations} replays)`) + const result = benchStream(renderer.name, fixture, iterations, renderer.run) + sink += result.checksum + results.push(result) } } - await writeFile(join(reportsDir, 'benchmarks.json'), JSON.stringify({ generatedAt: new Date().toISOString(), sink, results }, null, 2)) + const environment = { node: process.version, platform: process.platform, arch: process.arch, cpu: cpus()[0]?.model } + await writeFile(join(reportsDir, 'benchmarks.json'), JSON.stringify({ generatedAt: new Date().toISOString(), environment, sink, results }, null, 2)) await writeFile(join(reportsDir, 'benchmarks.md'), renderMarkdownReport(results)) } @@ -151,20 +165,15 @@ function markdownIterations(bytes: number): number { return 500 } -function replayStream(source: string, render: (prefix: string) => string): string { - const chunkSize = 32 - let output = '' - for (let end = chunkSize; end < source.length; end += chunkSize) output = render(source.slice(0, end)) - return render(source) -} - function renderMarkdownReport(results: BenchResult[]): string { const lines = [ '# Benchmark Results', '', `Generated: ${new Date().toISOString()}`, '', - 'Lower `ms/op` is better. Benchmarks run in Node with production package builds where available; heap delta is a coarse process-level signal, not an allocation profiler. Streaming rows replay the complete response in 32-character chunks, so one operation is one progressive response.', + `Environment: Node ${process.version}, ${process.platform} ${process.arch}, ${cpus()[0]?.model ?? 'unknown CPU'}.`, + '', + 'Lower `ms/op` is better. Benchmarks run in Node with production dependency builds where available and local Markdown source; heap delta is a coarse process-level signal, not an allocation profiler. Streaming rows replay the complete response in 32-character chunks, so one operation is one progressive response. Every prefix is parsed from scratch. Replay time includes slicing, timing, and collecting samples; per-update latency times only the parser or renderer call. Browser layout, framework updates, and network delays are excluded.', '', ] @@ -180,11 +189,25 @@ function renderMarkdownReport(results: BenchResult[]): string { lines.push('') } + lines.push('For persistent React and incremental DOM comparisons against streaming-focused libraries, see the [browser streaming report](./streaming-browser.md).', '') + lines.push('## Streaming update latency', '') + lines.push('All timings below are milliseconds, pooled across measured replays after two full warmup replays. Percentiles use nearest rank. Each update contributes its output to a checksum. Parse-only rows use the same streaming options as rendering rows and report zero HTML output bytes.', '') + lines.push('Generated fixtures contain a growing TypeScript fence with an unfinished final line. Backtick fences cover 4, 16, and 64 KiB; a 64 KiB tilde fence checks the other delimiter. The closing case appends a closing fence and trailing prose to the same 64 KiB body. Open-fence membership uses known fixture offsets, independently of the parser. The late-open sample covers prefixes in the last 10% of the source up to the closing fence, or EOF when it never closes.', '') + lines.push('The streaming highlighter is the installed @tanstack/highlight TypeScript tokenizer and HTML adapter, initialized before timing and called on every code block on every update. The non-streaming external-highlighter rows above use a line-wrapping stub. Parse-only and plain-render rows help compare parsing cost with rendering; these are separate runs, not an instrumented phase breakdown.', '') + lines.push('| Name | Fixture | Updates/replay | Open updates/replay | Update p50 | Update p95 | Open p50 | Open p95 | Open max | Late open p95 | Final p50 |', '| :--- | :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + const ms = (value: number | undefined) => value === undefined ? 'n/a' : value.toFixed(4) + for (const result of results.filter(result => result.group === 'streaming')) { + const { latency } = result + lines.push(`| ${result.name} | ${result.fixture} | ${result.updatesPerReplay} | ${result.openFenceUpdatesPerReplay} | ${ms(latency.all?.p50Ms)} | ${ms(latency.all?.p95Ms)} | ${ms(latency.openFence?.p50Ms)} | ${ms(latency.openFence?.p95Ms)} | ${ms(latency.openFence?.maxMs)} | ${ms(latency.lastTenPercentOpenFence?.p95Ms)} | ${ms(latency.finalUpdate?.p50Ms)} |`) + } + lines.push('') + lines.push('## Averages', '') lines.push('| Group | Name | Mean ms/op |') lines.push('| :--- | :--- | ---: |') const grouped = new Map() for (const result of results) { + if (result.group === 'streaming') continue const key = `${result.group}::${result.name}` grouped.set(key, [...(grouped.get(key) ?? []), result]) } diff --git a/scripts/streaming-bench.ts b/scripts/streaming-bench.ts new file mode 100644 index 0000000..b027205 --- /dev/null +++ b/scripts/streaming-bench.ts @@ -0,0 +1,123 @@ +import { performance } from 'node:perf_hooks' + +export interface StreamingFixture { + name: string + source: string + // Offsets are known from fixture construction, not inferred by the parser under test. + fenceOpenAt?: number + fenceClosedAt?: number +} + +export interface LatencySummary { + samples: number + meanMs: number + p50Ms: number + p95Ms: number + maxMs: number +} + +export function createAiResponseFixture(source: string): StreamingFixture { + const opening = source.indexOf('```ts\n') + const fenceOpenAt = opening + 6 + const closing = source.indexOf('\n```', fenceOpenAt) + if (opening < 0 || closing < 0) throw new Error('Expected a completed TypeScript fence in ai-response.md') + return { name: 'ai-response.md', source, fenceOpenAt, fenceClosedAt: closing + 4 } +} + +export function createFenceFixture(kib: number, fence = '```', closed = false): StreamingFixture { + const opening = `# Generated configuration\n\n${fence}ts\n` + const lines: string[] = [] + let length = opening.length + for (let index = 0; length < kib * 1024; index++) { + const line = `const route${index}: Route = { path: '/api/${index}', enabled: true }\n` + lines.push(line) + length += line.length + } + // ASCII makes the requested character and byte lengths identical. The last line is unfinished. + const source = (opening + lines.join('')).slice(0, kib * 1024) + return { + name: `${closed ? 'closing' : 'unfinished'}-${fence === '```' ? 'backtick' : 'tilde'}-${kib}kib`, + source: closed ? `${source}\n${fence}\n\nConfiguration complete.\n` : source, + fenceOpenAt: opening.length, + ...(closed ? { fenceClosedAt: source.length + 1 + fence.length } : {}), + } +} + +export function* streamUpdates(fixture: StreamingFixture, chunkSize = 32) { + if (!Number.isInteger(chunkSize) || chunkSize <= 0) throw new Error('chunkSize must be a positive integer') + for (let start = 0; start < fixture.source.length; start += chunkSize) { + const end = Math.min(start + chunkSize, fixture.source.length) + yield { + prefix: fixture.source.slice(0, end), + end, + openFence: fixture.fenceOpenAt !== undefined && end >= fixture.fenceOpenAt + && (fixture.fenceClosedAt === undefined || end < fixture.fenceClosedAt), + final: end === fixture.source.length, + } + } +} + +export function summarizeLatency(samples: number[]): LatencySummary | null { + if (!samples.length) return null + const sorted = [...samples].sort((a, b) => a - b) + return { + samples: sorted.length, + meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length, + p50Ms: sorted[Math.ceil(sorted.length * 0.5) - 1]!, + p95Ms: sorted[Math.ceil(sorted.length * 0.95) - 1]!, + maxMs: sorted[sorted.length - 1]!, + } +} + +export function benchStream(name: string, fixture: StreamingFixture, iterations: number, run: (source: string) => string | { children: unknown[] }) { + const chunkSize = 32 + const warmupReplays = 2 + let checksum = 0 + let outputBytes = 0 + const consume = (output: ReturnType) => { + checksum += typeof output === 'string' ? output.length : output.children.length + } + for (let index = 0; index < warmupReplays; index++) { + for (const update of streamUpdates(fixture, chunkSize)) consume(run(update.prefix)) + } + + const all: number[] = [] + const open: number[] = [] + const lateOpen: number[] = [] + const final: number[] = [] + const replays: number[] = [] + const heapBefore = process.memoryUsage().heapUsed + for (let index = 0; index < iterations; index++) { + const replayStart = performance.now() + for (const update of streamUpdates(fixture, chunkSize)) { + const start = performance.now() + const output = run(update.prefix) + const elapsed = performance.now() - start + consume(output) + all.push(elapsed) + if (update.openFence) { + open.push(elapsed) + if (update.end >= (fixture.fenceClosedAt ?? fixture.source.length) * 0.9) lateOpen.push(elapsed) + } + if (update.final) { + final.push(elapsed) + outputBytes = typeof output === 'string' ? Buffer.byteLength(output) : 0 + } + } + replays.push(performance.now() - replayStart) + } + + return { + group: 'streaming', name, fixture: fixture.name, + bytes: Buffer.byteLength(fixture.source), iterations, + msPerOp: replays.reduce((sum, value) => sum + value, 0) / iterations, + outputBytes, heapDeltaKb: (process.memoryUsage().heapUsed - heapBefore) / 1024, + chunkSize, warmupReplays, updatesPerReplay: Math.ceil(fixture.source.length / chunkSize), + openFenceUpdatesPerReplay: open.length / iterations, + checksum, + latency: { + all: summarizeLatency(all), openFence: summarizeLatency(open), + lastTenPercentOpenFence: summarizeLatency(lateOpen), finalUpdate: summarizeLatency(final), + }, + } +} diff --git a/scripts/streaming-browser.tsx b/scripts/streaming-browser.tsx new file mode 100644 index 0000000..548a616 --- /dev/null +++ b/scripts/streaming-browser.tsx @@ -0,0 +1,139 @@ +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import type { ReactNode } from 'react' +import { Streamdown } from 'streamdown' +import * as smd from 'streaming-markdown' +import { Markdown } from '../src/react.js' +import { streamingMarkdownExtension } from '../src/extensions/streaming.js' +import type { StreamingFixture } from './streaming-bench.js' + +export const rendererNames = ['TanStack Markdown React', 'Streamdown React', 'streaming-markdown DOM'] as const +export type RendererName = typeof rendererNames[number] + +const extensions = [streamingMarkdownExtension()] +const plainCodeComponents = { + pre: ({ children }: { children?: ReactNode }) =>
{children}
, + code: ({ children, className }: { children?: ReactNode; className?: string | undefined }) => {children}, +} + +function mount(name: RendererName, container: HTMLElement) { + if (name === 'streaming-markdown DOM') { + const parser = smd.parser(smd.default_renderer(container)) + return { + update: (_prefix: string, chunk: string) => smd.parser_write(parser, chunk), + finish: () => smd.parser_end(parser), + dispose: () => container.replaceChildren(), + } + } + const root = createRoot(container) + let lastPrefix = '' + const render = (prefix: string, streaming: boolean) => flushSync(() => root.render( + name === 'TanStack Markdown React' + ? {prefix} + : {prefix}, + )) + return { + update: (prefix: string, _chunk: string) => { lastPrefix = prefix; render(prefix, true) }, + finish: () => { if (name === 'Streamdown React') render(lastPrefix, false) }, + dispose: () => flushSync(() => root.unmount()), + } +} + +function updates(fixture: StreamingFixture) { + return Array.from({ length: Math.ceil(fixture.source.length / 32) }, (_, index) => { + const end = Math.min((index + 1) * 32, fixture.source.length) + return { + end, prefix: fixture.source.slice(0, end), chunk: fixture.source.slice(index * 32, end), + openFence: end >= fixture.fenceOpenAt! && (fixture.fenceClosedAt === undefined || end < fixture.fenceClosedAt), + } + }) +} + +export interface Validation { + passed: boolean + openUpdates: number + missingFenceUpdates: number + mismatchedCodeUpdates: number + maxBufferedCharacters: number + finalCodeMatches: boolean + finalTextDiffers?: boolean + firstMismatch?: { end: number; expectedTail: string; actualTail: string } + finalMismatch?: { expectedTail: string; actualTail: string } +} + +// Validate separately so DOM reads and string comparisons do not enter measured update times. +function validate(name: RendererName, fixture: StreamingFixture): Validation { + const container = document.getElementById('output')! + const renderer = mount(name, container) + const result: Validation = { passed: true, openUpdates: 0, missingFenceUpdates: 0, mismatchedCodeUpdates: 0, maxBufferedCharacters: 0, finalCodeMatches: false } + let expectedFinalCode = '' + for (const update of updates(fixture)) { + renderer.update(update.prefix, update.chunk) + if (!update.openFence) continue + result.openUpdates++ + const code = container.querySelector('pre code') + expectedFinalCode = update.prefix.slice(fixture.fenceOpenAt) + // remark-rehype appends a terminal newline to code; ignore that serializer difference. + const expected = expectedFinalCode.replace(/\n+$/, '') + const actual = (code?.textContent ?? '').replace(/\n+$/, '') + // Incremental parsers may hold the last delimiter or newline. Count that lag explicitly. + const buffered = expected.startsWith(actual) ? expected.length - actual.length : Infinity + if (!code) result.missingFenceUpdates++ + if (buffered > 3) result.mismatchedCodeUpdates++ + result.maxBufferedCharacters = Math.max(result.maxBufferedCharacters, Number.isFinite(buffered) ? buffered : expected.length) + if ((!code || buffered > 3) && !result.firstMismatch) { + result.firstMismatch = { end: update.end, expectedTail: expected.slice(-80), actualTail: actual.slice(-80) } + } + } + renderer.finish() + // Our generated closing cases end the same code body as the unfinished case. + const actualFinalCode = container.querySelector('pre code')?.textContent ?? '' + result.finalTextDiffers = actualFinalCode !== expectedFinalCode + result.finalCodeMatches = actualFinalCode.trimEnd() === expectedFinalCode.trimEnd() + if (!result.finalCodeMatches) result.finalMismatch = { expectedTail: expectedFinalCode.slice(-80), actualTail: actualFinalCode.slice(-80) } + result.passed = result.missingFenceUpdates === 0 && result.mismatchedCodeUpdates === 0 && result.finalCodeMatches + renderer.dispose() + return result +} + +export function runBrowserCase(name: RendererName, fixture: StreamingFixture, iterations: number) { + const validation = validate(name, fixture) + if (!validation.passed) return { name, fixture: fixture.name, validation, measured: null } + const container = document.getElementById('output')! + const prefixes = updates(fixture) + const samples: { end: number; openFence: boolean; updateMs: number; updateAndLayoutMs: number }[] = [] + const replayMs: number[] = [] + const finishMs: number[] = [] + let checksum = 0 + for (let iteration = -2; iteration < iterations; iteration++) { + const renderer = mount(name, container) + const replayStart = performance.now() + for (const update of prefixes) { + const start = performance.now() + renderer.update(update.prefix, update.chunk) + const committed = performance.now() + // Force style and layout after every update, without claiming to measure paint. + checksum += container.offsetHeight + const laidOut = performance.now() + if (iteration >= 0) samples.push({ end: update.end, openFence: update.openFence, updateMs: committed - start, updateAndLayoutMs: laidOut - start }) + } + const finishStart = performance.now() + renderer.finish() + checksum += container.offsetHeight + const end = performance.now() + if (iteration >= 0) { + finishMs.push(end - finishStart) + replayMs.push(end - replayStart) + } + checksum += container.textContent?.length ?? 0 + renderer.dispose() + } + return { name, fixture: fixture.name, validation, measured: { samples, replayMs, finishMs, checksum } } +} + +declare global { + interface Window { + runStreamingCase: typeof runBrowserCase + } +} +window.runStreamingCase = runBrowserCase diff --git a/scripts/verify-streaming-browser.ts b/scripts/verify-streaming-browser.ts new file mode 100644 index 0000000..583c081 --- /dev/null +++ b/scripts/verify-streaming-browser.ts @@ -0,0 +1,20 @@ +import { build } from 'esbuild' +import { chromium } from 'playwright' + +const bundle = await build({ + entryPoints: ['tests/browser/streaming.tsx'], bundle: true, write: false, + minify: true, platform: 'browser', format: 'iife', target: 'es2022', jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"production"' }, +}) +const browser = await chromium.launch({ headless: true, ...(process.env.BENCH_BROWSER_CHANNEL ? { channel: process.env.BENCH_BROWSER_CHANNEL } : {}) }) +try { + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + await page.setContent('') + await page.addScriptTag({ content: bundle.outputFiles[0]!.text }) + console.log(await page.evaluate(() => window.verifyStreamingReact())) + if (errors.length) throw new Error(errors.join('\n')) +} finally { + await browser.close() +} diff --git a/src/parser.ts b/src/parser.ts index f1749c7..0e418cf 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -46,13 +46,13 @@ export function parseMarkdown(markdown: string, options: ParseOptions = {}): Mar hasReferences || hasFootnotes ? { ...options, - ...(hasReferences ? { references: definitions.references } : {}), - ...(hasFootnotes ? { footnotes: definitions.footnotes, footnoteOrder, footnoteCounts } : {}), + ...(hasReferences && { references: definitions.references }), + ...(hasFootnotes && { footnotes: definitions.footnotes, footnoteOrder, footnoteCounts }), } : options const slugger = createSlugger() - const parser = new BlockParser(lines, parseOptions, slugger) + const parser = createBlockParser(lines, parseOptions, slugger) const children = parser.parse() if (hasFootnotes && footnoteOrder.length > 0) { children.push(createFootnotesBlock(definitions.footnotes, footnoteOrder, parseOptions, slugger)) @@ -66,73 +66,81 @@ export function parseMarkdown(markdown: string, options: ParseOptions = {}): Mar return document } -class BlockParser { - private index = 0 - loose = false - - constructor( - private readonly lines: string[], - private readonly options: ParseOptions, - private readonly slugger: Slugger, - private readonly budget: BlockParseBudget = { depth: 0 }, - ) {} - - parse(): BlockNode[] { - if (this.budget.depth >= maxBlockDepth) { - const value = this.lines.slice(this.index).join('\n') - this.index = this.lines.length - return value ? [{ type: 'paragraph', children: parseInline(value, this.options) }] : [] +function createBlockParser( + inputLines: string[], + options: ParseOptions, + slugger: Slugger, + budget: BlockParseBudget = { depth: 0 }, +) { + let cursor = 0 + let looseBlocks = false + return { + parse, + get loose() { + return looseBlocks + }, + } + + function parse(): BlockNode[] { + if (budget.depth >= maxBlockDepth) { + const value = inputLines.slice(cursor).join('\n') + cursor = inputLines.length + return value ? [{ type: 'paragraph', children: parseInline(value, options) }] : [] } - this.budget.depth++ + budget.depth++ const nodes: BlockNode[] = [] - while (this.index < this.lines.length) { - if (isBlank(this.current())) { - if (nodes.length) this.loose = true - this.index++ + while (cursor < inputLines.length) { + if (isBlank(current())) { + if (nodes.length) looseBlocks = true + cursor++ continue } - const extensionNode = this.parseExtensionBlock() + const extensionNode = parseExtensionBlock() if (extensionNode) { nodes.push(extensionNode) continue } + // Letter-led text cannot open a fence, heading, rule, quote, or list. + // It can still be a table header, so table detection remains below. const node = - this.parseFence() ?? - this.parseHeading() ?? - this.parseThematicBreak() ?? - this.parseBlockquote() ?? - this.parseList() ?? - this.parseTable() ?? - this.parseHtmlBlock() ?? - this.parseParagraph() + (/^[a-z]/i.test(current()) ? undefined : ( + parseFence() ?? + parseHeading() ?? + parseThematicBreak() ?? + parseBlockquote() ?? + parseList() + )) ?? + parseTable() ?? + parseHtmlBlock() ?? + parseParagraph() nodes.push(node) } - this.budget.depth-- + budget.depth-- return nodes } - private parseExtensionBlock(): BlockNode | undefined { - for (const extension of this.options.extensions ?? []) { + function parseExtensionBlock(): BlockNode | undefined { + for (const extension of options.extensions ?? []) { let consumed = 0 const node = extension.parseBlock?.({ - lines: this.lines, - index: this.index, - options: this.options, - parseInline: value => parseInline(value, this.options), - parseBlocks: value => new BlockParser(normalizeInput(value).split('\n'), this.options, this.slugger, this.budget).parse(), + lines: inputLines, + index: cursor, + options: options, + parseInline: value => parseInline(value, options), + parseBlocks: value => createBlockParser(normalizeInput(value).split('\n'), options, slugger, budget).parse(), consume: lines => { consumed = lines }, }) if (node) { - this.index += Math.max(consumed, 1) + cursor += Math.max(consumed, 1) return node } } @@ -140,24 +148,23 @@ class BlockParser { return undefined } - private parseFence(): CodeBlockNode | undefined { - const match = this.current().match(/^( {0,3})(`{3,}|~{3,})(.*)$/) + function parseFence(): CodeBlockNode | undefined { + const match = current().match(/^( {0,3})(`{3,}|~{3,})(.*)$/) if (!match) return undefined const fence = match[2]! const info = match[3]!.trim() const code: string[] = [] - this.index++ + cursor++ - while (this.index < this.lines.length) { - const line = this.current() - const close = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/) - if (close?.[1]!.startsWith(fence)) { - this.index++ + while (cursor < inputLines.length) { + const line = current() + if (line.includes(fence) && /^ {0,3}(?:`+|~+)\s*$/.test(line)) { + cursor++ break } code.push(stripIndent(line, match[1]!.length)) - this.index++ + cursor++ } return { @@ -167,49 +174,49 @@ class BlockParser { } } - private parseHeading(): HeadingNode | undefined { - const match = this.current().match(/^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$/) + function parseHeading(): HeadingNode | undefined { + const match = current().match(/^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$/) if (!match) return undefined const depth = match[1]!.length as HeadingNode['depth'] const rawValue = match[2] ?? '' const value = (/^#+[ \t]*$/.test(rawValue) ? '' : rawValue.replace(/[ \t]+#+[ \t]*$/, '')).trim() - const children = parseInline(value, this.options) - const id = this.createHeadingId(children) - this.index++ + const children = parseInline(value, options) + const id = createHeadingId(children) + cursor++ return id ? { type: 'heading', depth, id, children } : { type: 'heading', depth, children } } - private parseThematicBreak(): BlockNode | undefined { - if (!/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(this.current())) return undefined - this.index++ + function parseThematicBreak(): BlockNode | undefined { + if (!/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(current())) return undefined + cursor++ return { type: 'thematicBreak' } } - private parseBlockquote(): BlockNode | undefined { - if (!/^ {0,3}>\s?/.test(this.current())) return undefined + function parseBlockquote(): BlockNode | undefined { + if (!/^ {0,3}>\s?/.test(current())) return undefined const quoted: string[] = [] - while (this.index < this.lines.length) { - const line = this.current() + while (cursor < inputLines.length) { + const line = current() const match = line.match(/^ {0,3}>\s?(.*)$/) if (!match) { if (!isBlank(line)) break - this.loose = true + looseBlocks = true } quoted.push(match?.[1] ?? '') - this.index++ + cursor++ } return { type: 'blockquote', - children: new BlockParser(quoted, this.options, this.slugger, this.budget).parse(), + children: createBlockParser(quoted, options, slugger, budget).parse(), } } - private parseList(): ListNode | undefined { - const first = listMarker(this.current()) + function parseList(): ListNode | undefined { + const first = listMarker(current()) if (!first) return undefined const items: ListItemNode[] = [] @@ -217,8 +224,8 @@ class BlockParser { const baseIndent = first.indent let loose = false - while (this.index < this.lines.length) { - const marker = listMarker(this.current()) + while (cursor < inputLines.length) { + const marker = listMarker(current()) if (!marker || marker.marker !== first.marker || marker.indent !== baseIndent) break let firstLine = marker.content @@ -227,42 +234,42 @@ class BlockParser { if (task) firstLine = task[2]! const itemLines = [firstLine] - this.index++ + cursor++ - while (this.index < this.lines.length) { - const line = this.current() + while (cursor < inputLines.length) { + const line = current() const nextMarker = listMarker(line) if (nextMarker && nextMarker.indent === baseIndent) break if (isBlank(line)) { - let nextIndex = this.index - while (nextIndex < this.lines.length && isBlank(this.lines[nextIndex]!)) nextIndex++ - const following = this.lines[nextIndex] + let nextIndex = cursor + while (nextIndex < inputLines.length && isBlank(inputLines[nextIndex]!)) nextIndex++ + const following = inputLines[nextIndex] if (following === undefined) break const followingMarker = listMarker(following) if (followingMarker?.indent === baseIndent) { if (followingMarker.marker !== first.marker) break loose = true - this.index = nextIndex + cursor = nextIndex break } if (leadingSpaces(following) < marker.contentIndent) break - while (this.index < nextIndex) itemLines.push(stripIndent(this.lines[this.index++]!, marker.contentIndent)) + while (cursor < nextIndex) itemLines.push(stripIndent(inputLines[cursor++]!, marker.contentIndent)) continue } if (leadingSpaces(line) >= marker.contentIndent) { itemLines.push(stripIndent(line, marker.contentIndent)) - this.index++ + cursor++ continue } - if (isBlockStart(line, this.next())) break + if (isBlockStart(line, next())) break itemLines.push(line.trimStart()) - this.index++ + cursor++ } - const parser = new BlockParser(itemLines, this.options, this.slugger, this.budget) + const parser = createBlockParser(itemLines, options, slugger, budget) const children = parser.parse() loose ||= parser.loose const item: ListItemNode = { type: 'listItem', children } @@ -279,107 +286,103 @@ class BlockParser { } } - private parseTable(): TableNode | undefined { - const header = this.current() - const delimiter = this.next() + function parseTable(): TableNode | undefined { + const header = current() + const delimiter = next() if (!delimiter || !looksLikeTableHeader(header, delimiter)) return undefined const headerCells = splitTableRow(header) const align = splitTableRow(delimiter).map(parseAlign) const columns = headerCells.length const rows: TableCellNode[][] = [] - this.index += 2 + cursor += 2 - while (this.index < this.lines.length) { - const line = this.current() - if (isBlank(line) || isBlockStart(line, this.next())) break + while (cursor < inputLines.length) { + const line = current() + if (isBlank(line) || isBlockStart(line, next())) break const values = splitTableRow(line) - rows.push(Array.from({ length: columns }, (_, index) => cell(values[index] ?? '', this.options))) - this.index++ + rows.push(Array.from({ length: columns }, (_, index) => cell(values[index] ?? '', options))) + cursor++ } return { type: 'table', align, - header: headerCells.map(value => cell(value, this.options)), + header: headerCells.map(value => cell(value, options)), rows, } } - private parseHtmlBlock(): BlockNode | undefined { - if (!this.options.allowHtml || !/^ {0,3}<([A-Za-z][\w:-]*|!--|\/[A-Za-z])/.test(this.current())) return undefined + function parseHtmlBlock(): BlockNode | undefined { + if (!options.allowHtml || !/^ {0,3}<([A-Za-z][\w:-]*|!--|\/[A-Za-z])/.test(current())) return undefined const html: string[] = [] - if (/^ {0,3}')) break } return { type: 'html', value: html.join('\n') } } - while (this.index < this.lines.length && !isBlank(this.current())) { - html.push(this.current()) - this.index++ + while (cursor < inputLines.length && !isBlank(current())) { + html.push(current()) + cursor++ } return { type: 'html', value: html.join('\n') } } - private parseParagraph(): BlockNode { + function parseParagraph(): BlockNode { const lines: string[] = [] - while (this.index < this.lines.length) { - const line = this.current() + while (cursor < inputLines.length) { + const line = current() if (isBlank(line)) break - if (lines.length > 0 && isBlockStart(line, this.next())) break + if (lines.length > 0 && isBlockStart(line, next())) break lines.push(line.trim()) - this.index++ + cursor++ } return { type: 'paragraph', - children: parseInline(lines.join('\n'), this.options), + children: parseInline(lines.join('\n'), options), } } - private createHeadingId(children: InlineNode[]): string | undefined { - if (this.options.headingIds === false) return undefined + function createHeadingId(children: InlineNode[]): string | undefined { + if (options.headingIds === false) return undefined const text = plainText(children) - if (typeof this.options.headingIds === 'function') return this.options.headingIds(text, this.index) - return this.slugger(text) + if (typeof options.headingIds === 'function') return options.headingIds(text, cursor) + return slugger(text) } - private current(): string { - return this.lines[this.index] ?? '' + function current(): string { + return inputLines[cursor] ?? '' } - private next(): string | undefined { - return this.lines[this.index + 1] + function next(): string | undefined { + return inputLines[cursor + 1] } } function parseCodeInfo(info: string): Omit { - if (!info) return {} - const langMatch = info.match(/^([A-Za-z0-9_+.#-]+)/) const lang = langMatch?.[1] const meta = lang ? info.slice(lang.length).trim() : info - const titleMatch = meta.match(/(?:^|\s)(?:title|file)=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/) - const frameworkMatch = meta.match(/(?:^|\s)framework=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/) + const title = meta.match(/(?:^|\s)(?:title|file)=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/)?.slice(1).find(Boolean) + const framework = meta.match(/(?:^|\s)framework=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/)?.slice(1).find(Boolean) const rangeMatch = meta.match(/\{([^}]+)\}|(?:^|\s)lines=([^\s]+)/) const highlightLines = rangeMatch ? parseLineRanges(rangeMatch[1] ?? rangeMatch[2]!) : [] - const title = titleMatch ? titleMatch[1] ?? titleMatch[2] ?? titleMatch[3] : undefined - const framework = frameworkMatch ? frameworkMatch[1] ?? frameworkMatch[2] ?? frameworkMatch[3] : undefined return { - ...(lang ? { lang } : {}), - ...(meta ? { meta } : {}), - ...(title ? { title, file: title } : {}), - ...(framework ? { framework: framework.toLowerCase() } : {}), - ...(highlightLines.length ? { highlightLines } : {}), + ...(lang && { lang }), + ...(meta && { meta }), + ...(title && { title, file: title }), + ...(framework && { framework: framework.toLowerCase() }), + ...(highlightLines.length && { highlightLines }), } } @@ -393,7 +396,7 @@ function extractDefinitions(lines: string[]) { while (index < lines.length) { const line = lines[index]! - const fence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/) + const fence = (!activeFence || line.includes(activeFence)) && line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/) if (fence) { if (!activeFence) activeFence = fence[1]! else if (fence[1]!.startsWith(activeFence) && isBlank(fence[2]!)) activeFence = '' @@ -448,7 +451,7 @@ function createFootnotesBlock( items.push({ id: definition.id ?? footnoteId(definition.label), number: index + 1, - children: new BlockParser(normalizeInput(definition.content).split('\n'), options, slugger).parse(), + children: createBlockParser(normalizeInput(definition.content).split('\n'), options, slugger).parse(), }) } for (const item of items) { @@ -489,7 +492,7 @@ function listMarker(line: string): const ordered = /\d/.test(marker[0]!) return { ordered, - ...(ordered ? { number: Number.parseInt(marker, 10) } : {}), + ...(ordered && { number: Number.parseInt(marker, 10) }), indent: match[1]!.length, marker: ordered ? marker.at(-1)! : marker, contentIndent: match[1]!.length + marker.length + (match[3]?.length ?? 1), diff --git a/src/react.ts b/src/react.ts index 5db4670..e67931d 100644 --- a/src/react.ts +++ b/src/react.ts @@ -37,7 +37,7 @@ export function renderBlockReact(node: BlockNode, options: MarkdownReactOptions return h( options, `h${node.depth}`, - { key, ...(node.id ? { id: node.id } : {}), ...(node.framework ? { 'data-framework': node.framework } : {}) }, + { key, ...(node.id && { id: node.id }), ...(node.framework && { 'data-framework': node.framework }) }, renderInlines(node.children, options), renderHeadingAnchorReact(node.id, options), ) @@ -50,7 +50,7 @@ export function renderBlockReact(node: BlockNode, options: MarkdownReactOptions return h( options, tag, - { key, ...(node.ordered && node.start !== undefined && node.start !== 1 ? { start: node.start } : {}) }, + { key, ...(node.ordered && node.start !== undefined && node.start !== 1 && { start: node.start }) }, node.items.map((item, index) => h(options, 'li', { key: index }, renderListItemChildrenReact(item.children, item.checked, node.loose, options, `${index}`)), ), @@ -126,9 +126,9 @@ export function renderInlineReact(node: InlineNode, options: MarkdownReactOption ), ) case 'link': - return h(options, 'a', { key, href: node.href, ...(node.title ? { title: node.title } : {}) }, renderInlines(node.children, options)) + return h(options, 'a', { key, href: node.href, ...(node.title && { title: node.title }) }, renderInlines(node.children, options)) case 'image': - return h(options, 'img', { key, src: node.src, alt: node.alt, ...(node.title ? { title: node.title } : {}) }) + return h(options, 'img', { key, src: node.src, alt: node.alt, ...(node.title && { title: node.title }) }) case 'break': return h(options, 'br', { key }) case 'inlineHtml': @@ -148,7 +148,12 @@ function renderCodeBlockReact(node: Extract, option const lang = node.lang ?? 'plaintext' const highlighter = options.highlighter - const content = highlighter ? undefined : node.value + // Keep completed groups of lines in stable text nodes as code grows. Grouping + // bounds React's sibling work without relaying out the entire code block. + // Static rendering and custom code components retain string children. + const content = highlighter ? undefined : !options.components?.code && options.extensions?.some(extension => extension.name === 'streaming') + ? node.value.match(/(?:[^\n]*\n){1,4}|[^\n]+$/g) + : node.value const highlighted = highlighter @@ -167,17 +172,18 @@ function renderCodeBlockReact(node: Extract, option options, 'pre', { + key, className: `tm-code${options.codeLineNumbers ? ' tm-code--line-numbers' : ''}`, 'data-lang': lang, - ...(node.meta ? { 'data-meta': node.meta } : {}), - ...(node.title ? { 'data-code-title': node.title } : {}), - ...(node.file ? { 'data-filename': node.file } : {}), - ...(node.framework ? { 'data-framework': node.framework } : {}), + ...(node.meta && { 'data-meta': node.meta }), + ...(node.title && { 'data-code-title': node.title }), + ...(node.file && { 'data-filename': node.file }), + ...(node.framework && { 'data-framework': node.framework }), }, h(options, 'code', { className: `language-${lang}`, ...highlighted }, content), ) - if (!node.title) return h(options, Fragment, { key }, pre) + if (!node.title) return pre return h( options, @@ -226,7 +232,7 @@ function renderTableCellReact( options: MarkdownReactOptions, key: number, ): ReactElement { - return h(options, tag, { key, ...(align ? { style: { textAlign: align } } : {}) }, renderInlines(cell.children, options)) + return h(options, tag, { key, ...(align && { style: { textAlign: align } }) }, renderInlines(cell.children, options)) } function renderFootnotesReact(items: FootnoteItemNode[], options: MarkdownReactOptions, key?: string): ReactElement { @@ -290,7 +296,7 @@ function h(options: MarkdownReactOptions, tag: string | typeof Fragment, props: function renderComponentReact(node: ComponentNode | InlineComponentNode, options: MarkdownReactOptions, key?: string): ReactElement { const tag = node.tagName ?? (node.type === 'inlineComponent' ? 'span' : 'md-comment-component') const props: Record = { - ...(node.properties ?? {}), + ...node.properties, } if (!node.tagName) { diff --git a/tests/browser/streaming.tsx b/tests/browser/streaming.tsx new file mode 100644 index 0000000..0013e99 --- /dev/null +++ b/tests/browser/streaming.tsx @@ -0,0 +1,68 @@ +import { createElement } from 'react' +import { flushSync } from 'react-dom' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { Markdown } from '../../src/react.js' +import type { MarkdownProps } from '../../src/react.js' +import { parseMarkdown } from '../../src/parser.js' +import { streamingMarkdownExtension } from '../../src/extensions/streaming.js' + +const extensions = [streamingMarkdownExtension()] + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +async function verifyStreamingReact() { + const container = document.body.appendChild(document.createElement('div')) + const root = createRoot(container) + let checks = 0 + const render = (source: string, options: Omit = {}) => { + flushSync(() => root.render({source})) + const expected = parseMarkdown(source).children.find(node => node.type === 'code') + assert(expected?.type === 'code', 'Test source must contain code') + assert(container.querySelector('pre code')?.textContent === expected.value, 'Code changed during a client update') + checks++ + } + const code = Array.from({ length: 100 }, (_, index) => `${index}: & "quoted" 👩🏽‍💻 é\n`).join('') + const source = '```ts\n' + code + render(source) + const first = container.querySelector('code')!.firstChild! + const text = first.textContent + for (const suffix of ['tail', 'tail\n', 'tail\nnext', 'tail\nnext\n```', 'tail\nnext\n```\n\nAfter']) { + render(source + suffix) + assert(container.querySelector('code')!.firstChild === first, 'Appending code replaced settled DOM text') + assert(first.textContent === text, 'Appending code rewrote settled DOM text') + checks++ + } + for (const replacement of ['```\n', '```ts\nchanged\n', '```ts\n\n\n\n\n', source.replace('0:', 'edited:'), source.slice(0, 70), source]) render(replacement) + + let customChildren: unknown + render(source, { components: { code: ({ children }: { children: string }) => { + customChildren = children + return {children} + } } }) + assert(customChildren === code, 'Custom code components must still receive string children') + render(source, { highlighter: value => value.replaceAll('&', '&').replaceAll('<', '<') }) + render(source) + flushSync(() => root.unmount()) + + const errors: unknown[] = [] + container.innerHTML = renderToString(createElement(Markdown, { children: source, extensions })) + const serverText = container.querySelector('code')!.firstChild + const hydrated = hydrateRoot(container, {source}, { onRecoverableError: error => errors.push(error) }) + await new Promise(requestAnimationFrame) + await new Promise(requestAnimationFrame) + assert(errors.length === 0, 'Code text segmentation caused a hydration mismatch') + assert(container.querySelector('code')!.firstChild === serverText, 'Hydration replaced the server text') + flushSync(() => hydrated.render({source + 'hydrated update'})) + assert(container.querySelector('code')!.textContent === code + 'hydrated update', 'Hydrated code did not update correctly') + flushSync(() => hydrated.unmount()) + container.remove() + return { checks: checks + 4 } +} + +declare global { + interface Window { verifyStreamingReact: typeof verifyStreamingReact } +} +window.verifyStreamingReact = verifyStreamingReact diff --git a/tests/bundle-size.test.ts b/tests/bundle-size.test.ts index 5fef87a..ea64827 100644 --- a/tests/bundle-size.test.ts +++ b/tests/bundle-size.test.ts @@ -19,17 +19,17 @@ describe('bundle budgets', () => { ['react'], ) - // Measured ceilings for the #7, #9, and #11 fixes, with no spare headroom. + // Measured ceilings after the streaming layout optimization, with no spare headroom. // Extension entries retain their 0.0.14 ceilings. for (const [result, min, gzip, brotli] of [ - [parser, 13157, 4975, 4593], - [html, 18337, 6807, 6240], - [react, 18271, 6722, 6184], - [octane, 18283, 6727, 6194], - [pluggable, 18373, 6829, 6260], + [parser, 11949, 4894, 4539], + [html, 17139, 6729, 6158], + [react, 17147, 6676, 6170], + [octane, 17084, 6644, 6143], + [pluggable, 17175, 6751, 6184], [streaming, 699, 311, 253], [callouts, 506, 335, 278], - [reactStreaming, 18962, 6907, 6356], + [reactStreaming, 17839, 6860, 6302], [docs, 6423, 2292, 2073], [tabs, 3290, 1221, 1082], ] as const) { @@ -42,11 +42,11 @@ describe('bundle budgets', () => { it('also protects the complete namespace of every public entry point', async () => { const budgets: Record = { - '.': [18598, 6936, 6345], - './html': [18560, 6920, 6331], - './parser': [13289, 5061, 4664], - './react': [18470, 6828, 6279], - './octane': [18485, 6833, 6287], + '.': [17404, 6852, 6267], + './html': [17364, 6840, 6283], + './parser': [12082, 4981, 4599], + './react': [17347, 6790, 6241], + './octane': [17287, 6752, 6209], './extensions/callouts': [660, 432, 360], './extensions/comment-components': [1073, 647, 542], './extensions/docs': [6587, 2392, 2162], diff --git a/tests/streaming-bench.test.ts b/tests/streaming-bench.test.ts new file mode 100644 index 0000000..c535be0 --- /dev/null +++ b/tests/streaming-bench.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { benchStream, createAiResponseFixture, createFenceFixture, streamUpdates, summarizeLatency } from '../scripts/streaming-bench.js' +import { parseMarkdown } from '../src/index.js' + +describe('streaming benchmark coverage', () => { + it('samples the open fence in the original AI response fixture', () => { + const source = readFileSync(new URL('../fixtures/benchmark/ai-response.md', import.meta.url), 'utf8') + const fixture = createAiResponseFixture(source) + const updates = [...streamUpdates(fixture)] + const open = updates.filter(update => update.openFence) + expect(open.length).toBeGreaterThan(0) + expect(updates.at(-1)?.openFence).toBe(false) + for (const update of open) { + const code = parseMarkdown(update.prefix).children.at(-1) + expect(code?.type).toBe('code') + if (code?.type !== 'code') throw new Error('Expected a code block') + expect(code.value).toBe(update.prefix.slice(fixture.fenceOpenAt)) + } + expect(() => createAiResponseFixture('No fence')).toThrow('Expected a completed TypeScript fence') + }) + + it.each([31, 32, 33, 64])('renders the final prefix exactly once for %i characters', length => { + const updates = [...streamUpdates({ name: 'boundary', source: 'x'.repeat(length) })] + expect(updates.map(update => update.end)).toEqual( + Array.from({ length: Math.ceil(length / 32) }, (_, index) => Math.min((index + 1) * 32, length)), + ) + expect(updates.filter(update => update.final)).toHaveLength(1) + expect(updates.at(-1)?.prefix).toHaveLength(length) + }) + + it.each(['```', '~~~'])('keeps generated %s fences open through the last update', fence => { + for (const kib of [4, 16, 64]) { + const fixture = createFenceFixture(kib, fence) + expect(Buffer.byteLength(fixture.source)).toBe(kib * 1024) + const updates = [...streamUpdates(fixture)] + expect(updates.at(-1)?.openFence).toBe(true) + // Check actual AST contents at early, middle, and final prefixes. + const open = updates.filter(update => update.openFence) + for (const update of [open[0]!, open[Math.floor(open.length / 2)]!, open.at(-1)!]) { + const code = parseMarkdown(update.prefix).children.at(-1) + expect(code?.type).toBe('code') + if (code?.type !== 'code') throw new Error('Expected a code block') + expect(code.lang).toBe('ts') + expect(code.value).toBe(update.prefix.slice(fixture.fenceOpenAt)) + } + } + }) + + it('marks a split closing delimiter open until it is complete', () => { + const fixture = createFenceFixture(4, '```', true) + const updates = [...streamUpdates(fixture, 1)] + expect(updates.find(update => update.end === fixture.fenceClosedAt! - 1)?.openFence).toBe(true) + expect(updates.find(update => update.end === fixture.fenceClosedAt)?.openFence).toBe(false) + expect(updates.at(-1)?.openFence).toBe(false) + const document = parseMarkdown(fixture.source) + expect(document.children.map(node => node.type)).toEqual(['heading', 'code', 'paragraph']) + }) + + it('excludes warmups from samples and consumes every update output', () => { + const fixture = createFenceFixture(4, '```', true) + const updates = [...streamUpdates(fixture)] + let calls = 0 + const result = benchStream('test', fixture, 3, source => { + calls++ + return source + }) + expect(calls).toBe(updates.length * 5) + expect(result.checksum).toBe(updates.reduce((sum, update) => sum + update.prefix.length, 0) * 5) + expect(result.latency.all?.samples).toBe(updates.length * 3) + expect(result.latency.openFence?.samples).toBe(updates.filter(update => update.openFence).length * 3) + expect(result.latency.finalUpdate?.samples).toBe(3) + expect(result.outputBytes).toBe(Buffer.byteLength(fixture.source)) + }) + + it('calculates nearest-rank percentiles and distinguishes absent samples', () => { + expect(summarizeLatency([])).toBeNull() + expect(summarizeLatency([5, 1, 4, 2, 3])).toEqual({ samples: 5, meanMs: 3, p50Ms: 3, p95Ms: 5, maxMs: 5 }) + expect(summarizeLatency(Array.from({ length: 100 }, (_, index) => index + 1))?.p95Ms).toBe(95) + }) +})