From 20d32eb6be637f393afdf80ca9b052006d8bbec5 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 12:01:25 +0200 Subject: [PATCH 1/5] perf: reuse incremental parsing in React and Svelte streams --- AGENTS.md | 3 +- docs/content/3.rendering/5.react.md | 4 +- docs/content/3.rendering/6.svelte.md | 2 + packages/comark-react/README.md | 2 + packages/comark-react/package.json | 1 + .../comark-react/src/components/Markdown.tsx | 7 +- .../src/components/MarkdownClient.tsx | 36 ++- packages/comark-react/test/streaming.test.tsx | 226 ++++++++++++++++++ packages/comark-svelte/README.md | 2 + .../src/async/MarkdownAsync.svelte | 16 +- .../src/components/Markdown.svelte | 32 ++- packages/comark-svelte/src/internal/parse.ts | 14 ++ .../test/incremental-streaming.svelte.test.ts | 207 ++++++++++++++++ .../test-components/MarkdownBoundary.svelte | 12 + packages/comark/src/parse.ts | 3 +- packages/comark/test/streaming.test.ts | 12 + pnpm-lock.yaml | 78 +++++- test/bundle.test.ts | 4 +- 18 files changed, 627 insertions(+), 34 deletions(-) create mode 100644 packages/comark-react/test/streaming.test.tsx create mode 100644 packages/comark-svelte/src/internal/parse.ts create mode 100644 packages/comark-svelte/test/incremental-streaming.svelte.test.ts create mode 100644 packages/comark-svelte/test/test-components/MarkdownBoundary.svelte diff --git a/AGENTS.md b/AGENTS.md index 0336235e..127ae4e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,7 +218,7 @@ packages/comark-react/ │ ├── components/ │ │ ├── Markdown.tsx # High-level markdown → render component │ │ ├── MarkdownDocument.tsx # Low-level AST → render component -│ │ ├── MarkdownClient.tsx # Client-only markdown component +│ │ ├── MarkdownClient.tsx # Client-only markdown with a serialized incremental parser │ │ ├── MarkdownLive.tsx # Streaming/live markdown component │ │ ├── Math.tsx # Math rendering component │ │ └── Mermaid.tsx # Mermaid rendering component @@ -255,6 +255,7 @@ packages/comark-svelte/ ├── src/ │ ├── index.ts # Entry point (@comark/svelte) │ ├── types.ts # Shared prop interfaces +│ ├── internal/parse.ts # Serialized incremental parser, preserving plugin errors │ ├── components/ │ │ ├── Markdown.svelte # High-level markdown → render ($state + $effect) │ │ ├── MarkdownDocument.svelte # Low-level AST → render component diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index 9ec2deb1..5d22b488 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -635,7 +635,9 @@ Enable real-time rendering as content arrives, ideal for AI chat interfaces and ### Setup -Set `streaming` to `true` while content is being received, then `false` when done: +The client component reuses completed blocks while text is appended. Keep `options` and `plugins` references stable between updates. Replacing either creates a new parser. + +Set `streaming` to `true` while content is being received, then `false` when done. The final update parses the complete document: ```tsx [components/AiChat.tsx] import { useState } from 'react' diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index a09a9102..b106c14b 100644 --- a/docs/content/3.rendering/6.svelte.md +++ b/docs/content/3.rendering/6.svelte.md @@ -538,6 +538,8 @@ If no custom component matches, the tag renders as a native HTML element (via `< Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews. +`Markdown` and `MarkdownAsync` reuse completed blocks while `streaming` is true. Set it to false when the stream ends to run a final full parse. Changes to `options`, `plugins`, or `unwrap` create a new parser. + Set `streaming` to `true` while content is being received, then `false` when done: ```svelte [components/AiChat.svelte] diff --git a/packages/comark-react/README.md b/packages/comark-react/README.md index 0acd2fbe..b3adb01c 100644 --- a/packages/comark-react/README.md +++ b/packages/comark-react/README.md @@ -61,6 +61,8 @@ Heads up! ### Streaming +Streaming reuses completed blocks while text is appended. Keep parser options and plugin references stable between updates. Set `streaming` to `false` when the stream ends to parse the complete document. + ```tsx {content} diff --git a/packages/comark-react/package.json b/packages/comark-react/package.json index 59300519..d7fd0e75 100644 --- a/packages/comark-react/package.json +++ b/packages/comark-react/package.json @@ -55,6 +55,7 @@ "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", + "happy-dom": "^20.14.0", "react": "^19.2.7", "react-dom": "catalog:", "vitest": "catalog:" diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 6b54f797..51cf63a3 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -105,8 +105,8 @@ export interface MarkdownProps { export async function Markdown({ children, value, - options = {}, - plugins = [], + options, + plugins, unwrap = false, components: customComponents = {}, componentsManifest, @@ -139,7 +139,8 @@ export async function Markdown({ return ( { + let parser: ReturnType | undefined + let pending: Promise = Promise.resolve() + + // Keep streaming state in order without hiding plugin errors from Suspense. + return (source: string, streaming: boolean) => { + const run = () => { + parser ??= createMarkdownParser({ ...options, ...(unwrap ? { unwrap } : {}), plugins }) + return parser(source, { streaming }) + } + const result = pending.then(run, run) + pending = result + return result + } + }, [options, plugins, unwrap]) + const parsePromise = useMemo( - () => (isMarkdownDocument(content) ? Promise.resolve(content) : parseMarkdown(content, { ...options, plugins })), - [content] + () => (isMarkdownDocument(content) ? Promise.resolve(content) : parse(content, streaming)), + [content, parse, streaming] ) // Keep showing the previous parsed result while a new parse is pending — @@ -59,6 +80,7 @@ export function MarkdownClient({ children, value, options = {}, plugins = [], .. ) diff --git a/packages/comark-react/test/streaming.test.tsx b/packages/comark-react/test/streaming.test.tsx new file mode 100644 index 00000000..a1094b62 --- /dev/null +++ b/packages/comark-react/test/streaming.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment happy-dom +import React, { act, Component } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { ComarkPlugin, MarkdownDocument } from 'comark' +import { MarkdownClient } from '../src/components/MarkdownClient' +import { Markdown } from '../src/components/Markdown' +import type { MarkdownProps } from '../src/components/Markdown' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +let container: HTMLDivElement +let root: ReturnType +beforeEach(() => { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) +async function render(props: MarkdownProps) { + await act(async () => { + root.render() + }) +} +function observe() { + const inputs: string[] = [] + const trees: MarkdownDocument[] = [] + const plugin: ComarkPlugin = { + name: 'observe-stream', + pre(state) { + inputs.push(state.markdown) + }, + post(state) { + trees.push(state.tree) + }, + } + return { inputs, trees, plugins: [plugin] } +} + +describe('MarkdownClient streaming', () => { + it('reuses completed blocks and parses the whole input when streaming ends', async () => { + const probe = observe() + const first = '# Completed\n\nFirst paragraph.\n\nLast paragraph' + await render({ value: first, streaming: true, plugins: probe.plugins }) + const heading = probe.trees[0].nodes[0] + const next = first + ' grows' + await render({ value: next, streaming: true, plugins: probe.plugins }) + expect(probe.inputs[1]).not.toContain('# Completed') + expect(probe.trees[1].nodes[0]).toBe(heading) + expect(container.textContent).toContain('Last paragraph grows') + + await render({ value: next, streaming: false, plugins: probe.plugins }) + expect(probe.inputs.at(-1)).toBe(next) + expect(probe.trees.at(-1)?.nodes[0]).not.toBe(heading) + expect(container.querySelector('h1')?.textContent).toBe('Completed') + }) + + it('reuses completed blocks through the public Markdown wrapper', async () => { + const probe = observe() + const first = '# Completed\n\nFirst paragraph.\n\nLast paragraph' + await act(async () => { + root.render(await Markdown({ value: first, streaming: true, plugins: probe.plugins })) + }) + await act(async () => { + root.render(await Markdown({ value: first + ' grows', streaming: true, plugins: probe.plugins })) + }) + expect(probe.inputs[1]).not.toContain('# Completed') + expect(probe.trees[1].nodes[0]).toBe(probe.trees[0].nodes[0]) + expect(container.textContent).toContain('Last paragraph grows') + }) + + it('does not initialize plugins for a pre-parsed document', async () => { + const plugins: ComarkPlugin[] = [ + { + name: 'must-not-initialize', + markdownItPlugins: [ + () => { + throw new Error('unexpected parser initialization') + }, + ], + }, + ] + await render({ value: { nodes: [['p', {}, 'Already parsed']], frontmatter: {}, meta: {} }, plugins }) + expect(container.textContent).toBe('Already parsed') + }) + + it('keeps its parser when options and plugins are omitted', async () => { + const first = '# Completed\n\nFirst paragraph.\n\nLast' + await render({ value: first, streaming: true }) + const heading = container.querySelector('h1') + await render({ value: first + ' grows', streaming: true }) + expect(container.querySelector('h1')).toBe(heading) + expect(container.textContent).toContain('Last grows') + }) + + it('recreates the parser for option and plugin changes with unchanged input', async () => { + const initial = observe() + const value = '# Heading\n\nParagraph' + await render({ value, streaming: true, plugins: initial.plugins }) + expect(container.querySelector('h1')?.id).toBe('heading') + await render({ value, streaming: true, plugins: initial.plugins, options: { headingIds: false } }) + expect(container.querySelector('h1')?.hasAttribute('id')).toBe(false) + const replacement = observe() + await render({ value, streaming: true, plugins: replacement.plugins }) + expect(replacement.inputs).toEqual([value]) + expect(container.querySelector('h1')?.id).toBe('heading') + }) + + it('applies unwrap changes and bypasses parsing for documents', async () => { + const probe = observe() + await render({ value: 'Paragraph', plugins: probe.plugins }) + expect(container.querySelector('p')).not.toBeNull() + await render({ value: 'Paragraph', plugins: probe.plugins, unwrap: true }) + expect(container.querySelector('p')).toBeNull() + const count = probe.inputs.length + await render({ value: { nodes: [['p', {}, 'Already parsed']], frontmatter: {}, meta: {} }, plugins: probe.plugins }) + expect(probe.inputs).toHaveLength(count) + expect(container.textContent).toBe('Already parsed') + }) + + it('serializes overlapping plugin work and renders the latest update', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let active = 0 + let maxActive = 0 + let calls = 0 + const plugins: ComarkPlugin[] = [ + { + name: 'deferred', + async pre() { + calls++ + active++ + maxActive = Math.max(maxActive, active) + if (calls === 1) await gate + active-- + }, + }, + ] + await render({ value: 'First', plugins, streaming: true }) + await render({ value: 'First grows', plugins, streaming: true }) + expect(calls).toBe(1) + await act(async () => release()) + expect(maxActive).toBe(1) + expect(container.textContent).toBe('First grows') + }) + + it('discards pending output after replacement with a document', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const plugins: ComarkPlugin[] = [ + { + name: 'deferred', + async pre() { + await gate + }, + }, + ] + await render({ value: 'Pending', plugins, streaming: true }) + await render({ value: { nodes: [['p', {}, 'Document']], frontmatter: {}, meta: {} }, plugins }) + await act(async () => release()) + expect(container.textContent).toBe('Document') + }) + + it('keeps parser state separate between mounted components', async () => { + const a = observe() + const b = observe() + await act(async () => { + root.render( + <> + + + + ) + }) + expect(a.trees[0].nodes[0]).not.toBe(b.trees[0].nodes[0]) + expect(container.textContent).toContain('A') + expect(container.textContent).toContain('B') + }) + + it('delivers plugin errors to the error boundary', async () => { + class Boundary extends Component<{ children: React.ReactNode }, { error: boolean }> { + state = { error: false } + static getDerivedStateFromError() { + return { error: true } + } + render() { + return this.state.error ?

Parse failed

: this.props.children + } + } + const plugins: ComarkPlugin[] = [ + { + name: 'failure', + pre() { + throw new Error('plugin failed') + }, + }, + ] + await act(async () => { + root.render( + + + + ) + }) + expect(container.textContent).toBe('Parse failed') + }) +}) diff --git a/packages/comark-svelte/README.md b/packages/comark-svelte/README.md index 97c82463..2434bd39 100644 --- a/packages/comark-svelte/README.md +++ b/packages/comark-svelte/README.md @@ -59,6 +59,8 @@ Heads up! ### Streaming +`Markdown` and `MarkdownAsync` reuse completed blocks while `streaming` is true. Set it to false when the stream ends to run a final full parse. Changes to `options`, `plugins`, or `unwrap` create a new parser. + ```svelte ``` diff --git a/packages/comark-svelte/src/async/MarkdownAsync.svelte b/packages/comark-svelte/src/async/MarkdownAsync.svelte index 090dcdd5..5334746c 100644 --- a/packages/comark-svelte/src/async/MarkdownAsync.svelte +++ b/packages/comark-svelte/src/async/MarkdownAsync.svelte @@ -29,8 +29,8 @@ and wrap this component in a `` for pending/error states. --> diff --git a/packages/comark-svelte/src/components/Markdown.svelte b/packages/comark-svelte/src/components/Markdown.svelte index fdcffadf..61e40108 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -25,8 +25,8 @@ This is an alert component --> diff --git a/packages/comark-svelte/src/internal/parse.ts b/packages/comark-svelte/src/internal/parse.ts new file mode 100644 index 00000000..78005b9d --- /dev/null +++ b/packages/comark-svelte/src/internal/parse.ts @@ -0,0 +1,14 @@ +import { createMarkdownParser } from 'comark' +import type { ParserOptions } from 'comark' + +// Serialize access to the parser's stream state without hiding plugin errors. +export function createComponentParser(options: ParserOptions) { + const parse = createMarkdownParser(options) + let pending: Promise = Promise.resolve() + return (...args: Parameters) => { + const run = () => parse(...args) + const result = pending.then(run, run) + pending = result + return result + } +} diff --git a/packages/comark-svelte/test/incremental-streaming.svelte.test.ts b/packages/comark-svelte/test/incremental-streaming.svelte.test.ts new file mode 100644 index 00000000..44c216d0 --- /dev/null +++ b/packages/comark-svelte/test/incremental-streaming.svelte.test.ts @@ -0,0 +1,207 @@ +import type { ComarkPlugin } from 'comark' +import { parseMarkdown } from 'comark' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import Markdown from '../src/components/Markdown.svelte' +import MarkdownAsync from '../src/async/MarkdownAsync.svelte' +import MarkdownBoundary from './test-components/MarkdownBoundary.svelte' + +for (const [name, component] of [ + ['Markdown', Markdown], + ['MarkdownAsync', MarkdownAsync], +] as const) { + describe(`${name} incremental parsing`, () => { + it('parses only the open tail and reparses the full value when streaming ends', async () => { + const inputs: string[] = [] + const plugin: ComarkPlugin = { + name: 'inputs', + pre: (state) => { + inputs.push(state.markdown) + }, + } + const initial = 'First\n\nSecond\n\nThird' + const screen = await render(MarkdownBoundary, { + component, + value: initial, + plugins: [plugin], + streaming: true, + }) + await expect.element(screen.getByText('Third')).toBeInTheDocument() + + const value = `${initial} grows` + await screen.rerender({ value }) + await expect.element(screen.getByText('Third grows')).toBeInTheDocument() + expect(inputs).toHaveLength(2) + expect(inputs[1]).not.toContain('First') + expect(inputs[1]!.length).toBeLessThan(value.length) + await expect.element(screen.getByText('First')).toBeInTheDocument() + + await screen.rerender({ streaming: false }) + await expect.poll(() => inputs.length).toBe(3) + expect(inputs[2]).toBe(value) + + await screen.rerender({ value: 'Replacement' }) + await expect.element(screen.getByText('Replacement')).toBeInTheDocument() + expect(screen.container.textContent).not.toContain('First') + }) + + it('recreates the parser when options, plugins, or unwrap change', async () => { + const value = 'https://example.com\n\nTail' + const screen = await render(MarkdownBoundary, { + component, + value, + streaming: true, + options: { linkify: false }, + }) + await expect.element(screen.getByText('Tail')).toBeInTheDocument() + expect(screen.container.querySelector('a')).toBeNull() + + await screen.rerender({ options: { linkify: true } }) + await expect.element(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com') + + const plugin: ComarkPlugin = { + name: 'replace', + pre: (state) => { + state.markdown = state.markdown.replace('Tail', 'Changed') + }, + } + await screen.rerender({ plugins: [plugin] }) + await expect.element(screen.getByText('Changed')).toBeInTheDocument() + + await screen.rerender({ unwrap: true }) + await expect.poll(() => screen.container.querySelector('p')).toBeNull() + expect(screen.container.textContent).toContain('Changed') + }) + + it('bypasses parsing for documents and resumes with a new string', async () => { + const inputs: string[] = [] + const plugin: ComarkPlugin = { + name: 'inputs', + pre: (state) => { + inputs.push(state.markdown) + }, + } + const screen = await render(MarkdownBoundary, { + component, + value: await parseMarkdown('Document'), + plugins: [plugin], + streaming: true, + }) + await expect.element(screen.getByText('Document')).toBeInTheDocument() + expect(inputs).toEqual([]) + await screen.rerender({ value: 'String' }) + await expect.element(screen.getByText('String')).toBeInTheDocument() + expect(inputs).toEqual(['String']) + }) + + it('ignores old plugin results after a configuration change', async () => { + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let finished = false + const screen = await render(MarkdownBoundary, { + component, + value: 'Old', + streaming: true, + plugins: [ + { + name: 'slow', + async pre() { + await gate + finished = true + }, + }, + ], + }) + await screen.rerender({ value: 'New', plugins: [] }) + await expect.element(screen.getByText('New')).toBeInTheDocument() + release() + await expect.poll(() => finished).toBe(true) + await expect.element(screen.getByText('New')).toBeInTheDocument() + expect(screen.container.textContent).not.toContain('Old') + }) + + it('serializes overlapping plugin work and applies the newest update', async () => { + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let active = 0 + let maxActive = 0 + const inputs: string[] = [] + const plugin: ComarkPlugin = { + name: 'deferred', + async pre(state) { + active++ + maxActive = Math.max(maxActive, active) + inputs.push(state.markdown) + if (state.markdown.includes('Slow')) await gate + active-- + }, + } + const screen = await render(MarkdownBoundary, { component, value: 'Ready', streaming: true, plugins: [plugin] }) + await expect.element(screen.getByText('Ready')).toBeInTheDocument() + await screen.rerender({ value: 'Slow' }) + await expect.poll(() => inputs).toContain('Slow') + await screen.rerender({ value: 'Latest' }) + release() + await expect.element(screen.getByText('Latest')).toBeInTheDocument() + expect(maxActive).toBe(1) + expect(screen.container.textContent).not.toContain('Slow') + }) + }) +} + +it('passes asynchronous plugin errors to the Svelte boundary', async () => { + const screen = await render(MarkdownBoundary, { + component: MarkdownAsync, + value: 'Failure', + streaming: true, + plugins: [ + { + name: 'fail', + async pre() { + throw new Error('Plugin failed') + }, + }, + ], + }) + await expect.element(screen.getByRole('alert')).toHaveTextContent('Plugin failed') +}) + +it('shows completed updates while the next update is still parsing', async () => { + let releaseFirst = () => {} + let releaseLast = () => {} + const first = new Promise((resolve) => { + releaseFirst = resolve + }) + const last = new Promise((resolve) => { + releaseLast = resolve + }) + const started: string[] = [] + const screen = await render(MarkdownBoundary, { + component: Markdown, + value: 'Ready', + streaming: true, + plugins: [ + { + name: 'slow', + async pre(state) { + started.push(state.markdown) + if (state.markdown === 'First') await first + if (state.markdown === 'Last') await last + }, + } satisfies ComarkPlugin, + ], + }) + await expect.element(screen.getByText('Ready')).toBeInTheDocument() + await screen.rerender({ value: 'First' }) + await expect.poll(() => started).toContain('First') + await screen.rerender({ value: 'Last' }) + releaseFirst() + await expect.poll(() => started).toContain('Last') + await expect.element(screen.getByText('First')).toBeInTheDocument() + releaseLast() + await expect.element(screen.getByText('Last')).toBeInTheDocument() +}) diff --git a/packages/comark-svelte/test/test-components/MarkdownBoundary.svelte b/packages/comark-svelte/test/test-components/MarkdownBoundary.svelte new file mode 100644 index 00000000..0d11094c --- /dev/null +++ b/packages/comark-svelte/test/test-components/MarkdownBoundary.svelte @@ -0,0 +1,12 @@ + + + + + {#snippet pending()}

Loading

{/snippet} + {#snippet failed(error)}

{error instanceof Error ? error.message : String(error)}

{/snippet} +
diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index fa55a422..ea538a4b 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -191,7 +191,8 @@ export function createMarkdownParser { expect(result1.frontmatter).toEqual({ title: 'Hello' }) expect(result2.frontmatter).toEqual({ title: 'Hello' }) }) + + it.each([ + ['without frontmatter', '# Replacement', {}], + ['with new frontmatter', '---\ntitle: New\n---\n\n# Replacement', { title: 'New' }], + ])('replaces stream frontmatter when the source changes %s', async (_, replacement, frontmatter) => { + const parse = createMarkdownParser() + await parse('---\ntitle: Old\n---\n\n# Original', { streaming: true }) + + const result = await parse(replacement, { streaming: true }) + + expect(result.frontmatter).toEqual(frontmatter) + }) }) describe('streaming with MDC components', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 202e99c1..b850691a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1401,6 +1401,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.17) + happy-dom: + specifier: ^20.14.0 + version: 20.14.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -1409,7 +1412,7 @@ importers: version: 19.2.7(react@19.2.7) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.14.0)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) packages/comark-svelte: dependencies: @@ -6404,6 +6407,12 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/types@8.63.0': resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7274,6 +7283,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -8486,6 +8499,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.14.0: + resolution: {integrity: sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ==} + engines: {node: '>=20.0.0'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -11962,6 +11979,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -17659,6 +17680,12 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.2.0 + '@typescript-eslint/types@8.63.0': optional: true @@ -18681,6 +18708,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.2.0 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -20199,6 +20230,19 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.14.0: + dependencies: + '@types/node': 26.2.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -25565,6 +25609,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.14.0)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.2.0 + '@vitest/browser-playwright': 4.1.10(playwright@1.61.1)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.10) + happy-dom: 20.14.0 + transitivePeerDependencies: + - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -25729,6 +25803,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/test/bundle.test.ts b/test/bundle.test.ts index c0dc230e..20a82def 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -64,8 +64,8 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/ansi": "36.6k (98 files)", "@comark/html": "15.7k (58 files)", "@comark/nuxt": "11.8k (58 files)", - "@comark/react": "36.9k (74 files)", - "@comark/svelte": "43.9k (82 files)", + "@comark/react": "37.6k (74 files)", + "@comark/svelte": "44.9k (84 files)", "@comark/vue": "51.7k (78 files)", "comark": "363k (156 files)", } From e336f65d0070e869a0d61e7708e8887a69a5b44c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 12:34:43 +0200 Subject: [PATCH 2/5] refactor: keep Svelte streaming changes in this PR --- AGENTS.md | 2 +- docs/content/3.rendering/5.react.md | 4 +- packages/comark-react/README.md | 2 - packages/comark-react/package.json | 1 - .../comark-react/src/components/Markdown.tsx | 7 +- .../src/components/MarkdownClient.tsx | 36 +-- packages/comark-react/test/streaming.test.tsx | 226 ------------------ .../test/incremental-streaming.svelte.test.ts | 42 +--- pnpm-lock.yaml | 78 +----- test/bundle.test.ts | 2 +- 10 files changed, 18 insertions(+), 382 deletions(-) delete mode 100644 packages/comark-react/test/streaming.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 127ae4e7..e15031a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,7 +218,7 @@ packages/comark-react/ │ ├── components/ │ │ ├── Markdown.tsx # High-level markdown → render component │ │ ├── MarkdownDocument.tsx # Low-level AST → render component -│ │ ├── MarkdownClient.tsx # Client-only markdown with a serialized incremental parser +│ │ ├── MarkdownClient.tsx # Client-only markdown component │ │ ├── MarkdownLive.tsx # Streaming/live markdown component │ │ ├── Math.tsx # Math rendering component │ │ └── Mermaid.tsx # Mermaid rendering component diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index 5d22b488..9ec2deb1 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -635,9 +635,7 @@ Enable real-time rendering as content arrives, ideal for AI chat interfaces and ### Setup -The client component reuses completed blocks while text is appended. Keep `options` and `plugins` references stable between updates. Replacing either creates a new parser. - -Set `streaming` to `true` while content is being received, then `false` when done. The final update parses the complete document: +Set `streaming` to `true` while content is being received, then `false` when done: ```tsx [components/AiChat.tsx] import { useState } from 'react' diff --git a/packages/comark-react/README.md b/packages/comark-react/README.md index b3adb01c..0acd2fbe 100644 --- a/packages/comark-react/README.md +++ b/packages/comark-react/README.md @@ -61,8 +61,6 @@ Heads up! ### Streaming -Streaming reuses completed blocks while text is appended. Keep parser options and plugin references stable between updates. Set `streaming` to `false` when the stream ends to parse the complete document. - ```tsx {content} diff --git a/packages/comark-react/package.json b/packages/comark-react/package.json index d7fd0e75..59300519 100644 --- a/packages/comark-react/package.json +++ b/packages/comark-react/package.json @@ -55,7 +55,6 @@ "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", - "happy-dom": "^20.14.0", "react": "^19.2.7", "react-dom": "catalog:", "vitest": "catalog:" diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 51cf63a3..6b54f797 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -105,8 +105,8 @@ export interface MarkdownProps { export async function Markdown({ children, value, - options, - plugins, + options = {}, + plugins = [], unwrap = false, components: customComponents = {}, componentsManifest, @@ -139,8 +139,7 @@ export async function Markdown({ return ( { - let parser: ReturnType | undefined - let pending: Promise = Promise.resolve() - - // Keep streaming state in order without hiding plugin errors from Suspense. - return (source: string, streaming: boolean) => { - const run = () => { - parser ??= createMarkdownParser({ ...options, ...(unwrap ? { unwrap } : {}), plugins }) - return parser(source, { streaming }) - } - const result = pending.then(run, run) - pending = result - return result - } - }, [options, plugins, unwrap]) - + // Re-creates the promise only when content changes. + // Note: options/plugins should be stable references (defined outside render or memoized). + // Pre-parsed documents resolve immediately without calling parseMarkdown(). const parsePromise = useMemo( - () => (isMarkdownDocument(content) ? Promise.resolve(content) : parse(content, streaming)), - [content, parse, streaming] + () => (isMarkdownDocument(content) ? Promise.resolve(content) : parseMarkdown(content, { ...options, plugins })), + [content] ) // Keep showing the previous parsed result while a new parse is pending — @@ -80,7 +59,6 @@ export function MarkdownClient({ ) diff --git a/packages/comark-react/test/streaming.test.tsx b/packages/comark-react/test/streaming.test.tsx deleted file mode 100644 index a1094b62..00000000 --- a/packages/comark-react/test/streaming.test.tsx +++ /dev/null @@ -1,226 +0,0 @@ -// @vitest-environment happy-dom -import React, { act, Component } from 'react' -import { createRoot } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { ComarkPlugin, MarkdownDocument } from 'comark' -import { MarkdownClient } from '../src/components/MarkdownClient' -import { Markdown } from '../src/components/Markdown' -import type { MarkdownProps } from '../src/components/Markdown' - -Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) - -let container: HTMLDivElement -let root: ReturnType -beforeEach(() => { - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) -afterEach(async () => { - await act(async () => root.unmount()) - container.remove() -}) -async function render(props: MarkdownProps) { - await act(async () => { - root.render() - }) -} -function observe() { - const inputs: string[] = [] - const trees: MarkdownDocument[] = [] - const plugin: ComarkPlugin = { - name: 'observe-stream', - pre(state) { - inputs.push(state.markdown) - }, - post(state) { - trees.push(state.tree) - }, - } - return { inputs, trees, plugins: [plugin] } -} - -describe('MarkdownClient streaming', () => { - it('reuses completed blocks and parses the whole input when streaming ends', async () => { - const probe = observe() - const first = '# Completed\n\nFirst paragraph.\n\nLast paragraph' - await render({ value: first, streaming: true, plugins: probe.plugins }) - const heading = probe.trees[0].nodes[0] - const next = first + ' grows' - await render({ value: next, streaming: true, plugins: probe.plugins }) - expect(probe.inputs[1]).not.toContain('# Completed') - expect(probe.trees[1].nodes[0]).toBe(heading) - expect(container.textContent).toContain('Last paragraph grows') - - await render({ value: next, streaming: false, plugins: probe.plugins }) - expect(probe.inputs.at(-1)).toBe(next) - expect(probe.trees.at(-1)?.nodes[0]).not.toBe(heading) - expect(container.querySelector('h1')?.textContent).toBe('Completed') - }) - - it('reuses completed blocks through the public Markdown wrapper', async () => { - const probe = observe() - const first = '# Completed\n\nFirst paragraph.\n\nLast paragraph' - await act(async () => { - root.render(await Markdown({ value: first, streaming: true, plugins: probe.plugins })) - }) - await act(async () => { - root.render(await Markdown({ value: first + ' grows', streaming: true, plugins: probe.plugins })) - }) - expect(probe.inputs[1]).not.toContain('# Completed') - expect(probe.trees[1].nodes[0]).toBe(probe.trees[0].nodes[0]) - expect(container.textContent).toContain('Last paragraph grows') - }) - - it('does not initialize plugins for a pre-parsed document', async () => { - const plugins: ComarkPlugin[] = [ - { - name: 'must-not-initialize', - markdownItPlugins: [ - () => { - throw new Error('unexpected parser initialization') - }, - ], - }, - ] - await render({ value: { nodes: [['p', {}, 'Already parsed']], frontmatter: {}, meta: {} }, plugins }) - expect(container.textContent).toBe('Already parsed') - }) - - it('keeps its parser when options and plugins are omitted', async () => { - const first = '# Completed\n\nFirst paragraph.\n\nLast' - await render({ value: first, streaming: true }) - const heading = container.querySelector('h1') - await render({ value: first + ' grows', streaming: true }) - expect(container.querySelector('h1')).toBe(heading) - expect(container.textContent).toContain('Last grows') - }) - - it('recreates the parser for option and plugin changes with unchanged input', async () => { - const initial = observe() - const value = '# Heading\n\nParagraph' - await render({ value, streaming: true, plugins: initial.plugins }) - expect(container.querySelector('h1')?.id).toBe('heading') - await render({ value, streaming: true, plugins: initial.plugins, options: { headingIds: false } }) - expect(container.querySelector('h1')?.hasAttribute('id')).toBe(false) - const replacement = observe() - await render({ value, streaming: true, plugins: replacement.plugins }) - expect(replacement.inputs).toEqual([value]) - expect(container.querySelector('h1')?.id).toBe('heading') - }) - - it('applies unwrap changes and bypasses parsing for documents', async () => { - const probe = observe() - await render({ value: 'Paragraph', plugins: probe.plugins }) - expect(container.querySelector('p')).not.toBeNull() - await render({ value: 'Paragraph', plugins: probe.plugins, unwrap: true }) - expect(container.querySelector('p')).toBeNull() - const count = probe.inputs.length - await render({ value: { nodes: [['p', {}, 'Already parsed']], frontmatter: {}, meta: {} }, plugins: probe.plugins }) - expect(probe.inputs).toHaveLength(count) - expect(container.textContent).toBe('Already parsed') - }) - - it('serializes overlapping plugin work and renders the latest update', async () => { - let release: () => void = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - let active = 0 - let maxActive = 0 - let calls = 0 - const plugins: ComarkPlugin[] = [ - { - name: 'deferred', - async pre() { - calls++ - active++ - maxActive = Math.max(maxActive, active) - if (calls === 1) await gate - active-- - }, - }, - ] - await render({ value: 'First', plugins, streaming: true }) - await render({ value: 'First grows', plugins, streaming: true }) - expect(calls).toBe(1) - await act(async () => release()) - expect(maxActive).toBe(1) - expect(container.textContent).toBe('First grows') - }) - - it('discards pending output after replacement with a document', async () => { - let release: () => void = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - const plugins: ComarkPlugin[] = [ - { - name: 'deferred', - async pre() { - await gate - }, - }, - ] - await render({ value: 'Pending', plugins, streaming: true }) - await render({ value: { nodes: [['p', {}, 'Document']], frontmatter: {}, meta: {} }, plugins }) - await act(async () => release()) - expect(container.textContent).toBe('Document') - }) - - it('keeps parser state separate between mounted components', async () => { - const a = observe() - const b = observe() - await act(async () => { - root.render( - <> - - - - ) - }) - expect(a.trees[0].nodes[0]).not.toBe(b.trees[0].nodes[0]) - expect(container.textContent).toContain('A') - expect(container.textContent).toContain('B') - }) - - it('delivers plugin errors to the error boundary', async () => { - class Boundary extends Component<{ children: React.ReactNode }, { error: boolean }> { - state = { error: false } - static getDerivedStateFromError() { - return { error: true } - } - render() { - return this.state.error ?

Parse failed

: this.props.children - } - } - const plugins: ComarkPlugin[] = [ - { - name: 'failure', - pre() { - throw new Error('plugin failed') - }, - }, - ] - await act(async () => { - root.render( - - - - ) - }) - expect(container.textContent).toBe('Parse failed') - }) -}) diff --git a/packages/comark-svelte/test/incremental-streaming.svelte.test.ts b/packages/comark-svelte/test/incremental-streaming.svelte.test.ts index 44c216d0..2b1ef105 100644 --- a/packages/comark-svelte/test/incremental-streaming.svelte.test.ts +++ b/packages/comark-svelte/test/incremental-streaming.svelte.test.ts @@ -1,5 +1,4 @@ import type { ComarkPlugin } from 'comark' -import { parseMarkdown } from 'comark' import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import Markdown from '../src/components/Markdown.svelte' @@ -73,32 +72,8 @@ for (const [name, component] of [ expect(screen.container.textContent).toContain('Changed') }) - it('bypasses parsing for documents and resumes with a new string', async () => { - const inputs: string[] = [] - const plugin: ComarkPlugin = { - name: 'inputs', - pre: (state) => { - inputs.push(state.markdown) - }, - } - const screen = await render(MarkdownBoundary, { - component, - value: await parseMarkdown('Document'), - plugins: [plugin], - streaming: true, - }) - await expect.element(screen.getByText('Document')).toBeInTheDocument() - expect(inputs).toEqual([]) - await screen.rerender({ value: 'String' }) - await expect.element(screen.getByText('String')).toBeInTheDocument() - expect(inputs).toEqual(['String']) - }) - it('ignores old plugin results after a configuration change', async () => { - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) + const { promise: gate, resolve: release } = Promise.withResolvers() let finished = false const screen = await render(MarkdownBoundary, { component, @@ -123,10 +98,7 @@ for (const [name, component] of [ }) it('serializes overlapping plugin work and applies the newest update', async () => { - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) + const { promise: gate, resolve: release } = Promise.withResolvers() let active = 0 let maxActive = 0 const inputs: string[] = [] @@ -171,14 +143,8 @@ it('passes asynchronous plugin errors to the Svelte boundary', async () => { }) it('shows completed updates while the next update is still parsing', async () => { - let releaseFirst = () => {} - let releaseLast = () => {} - const first = new Promise((resolve) => { - releaseFirst = resolve - }) - const last = new Promise((resolve) => { - releaseLast = resolve - }) + const { promise: first, resolve: releaseFirst } = Promise.withResolvers() + const { promise: last, resolve: releaseLast } = Promise.withResolvers() const started: string[] = [] const screen = await render(MarkdownBoundary, { component: Markdown, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b850691a..202e99c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1401,9 +1401,6 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.17) - happy-dom: - specifier: ^20.14.0 - version: 20.14.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -1412,7 +1409,7 @@ importers: version: 19.2.7(react@19.2.7) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.14.0)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) packages/comark-svelte: dependencies: @@ -6407,12 +6404,6 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} - '@types/whatwg-mimetype@3.0.2': - resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} - - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/types@8.63.0': resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7283,10 +7274,6 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer-image-size@0.6.4: - resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} - engines: {node: '>=4.0'} - buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -8499,10 +8486,6 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.14.0: - resolution: {integrity: sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ==} - engines: {node: '>=20.0.0'} - has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -11979,10 +11962,6 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - whatwg-mimetype@3.0.0: - resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} - engines: {node: '>=12'} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -17680,12 +17659,6 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@types/whatwg-mimetype@3.0.2': {} - - '@types/ws@8.18.1': - dependencies: - '@types/node': 26.2.0 - '@typescript-eslint/types@8.63.0': optional: true @@ -18708,10 +18681,6 @@ snapshots: buffer-from@1.1.2: {} - buffer-image-size@0.6.4: - dependencies: - '@types/node': 26.2.0 - buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -20230,19 +20199,6 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.14.0: - dependencies: - '@types/node': 26.2.0 - '@types/whatwg-mimetype': 3.0.2 - '@types/ws': 8.18.1 - buffer-image-size: 0.6.4 - entities: 7.0.1 - whatwg-mimetype: 3.0.0 - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -25609,36 +25565,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.14.0)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 26.2.0 - '@vitest/browser-playwright': 4.1.10(playwright@1.61.1)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.10) - happy-dom: 20.14.0 - transitivePeerDependencies: - - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -25803,8 +25729,6 @@ snapshots: webpack-virtual-modules@0.6.2: {} - whatwg-mimetype@3.0.0: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 20a82def..5e14daa9 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -64,7 +64,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/ansi": "36.6k (98 files)", "@comark/html": "15.7k (58 files)", "@comark/nuxt": "11.8k (58 files)", - "@comark/react": "37.6k (74 files)", + "@comark/react": "36.9k (74 files)", "@comark/svelte": "44.9k (84 files)", "@comark/vue": "51.7k (78 files)", "comark": "363k (156 files)", From e49a6a4cea7df3da139accffc2c76b7a480ec957 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 13:04:17 +0200 Subject: [PATCH 3/5] fix(svelte): preserve streamed headings and reference links --- docs/content/3.rendering/6.svelte.md | 2 +- .../2.vite/svelte/src/pages/Syntax.svelte | 2 +- packages/comark-svelte/README.md | 2 +- .../src/internal/parse/token-processor.ts | 2 ++ packages/comark/src/parse.ts | 11 +++++--- packages/comark/test/streaming.test.ts | 25 +++++++++++++++++++ test/bundle.test.ts | 2 +- 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index b106c14b..01e508f2 100644 --- a/docs/content/3.rendering/6.svelte.md +++ b/docs/content/3.rendering/6.svelte.md @@ -538,7 +538,7 @@ If no custom component matches, the tag renders as a native HTML element (via `< Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews. -`Markdown` and `MarkdownAsync` reuse completed blocks while `streaming` is true. Set it to false when the stream ends to run a final full parse. Changes to `options`, `plugins`, or `unwrap` create a new parser. +`Markdown` and `MarkdownAsync` reuse completed blocks while `streaming` is true. Set it to false when the stream ends to run a final full parse. Changes to `options`, `plugins`, or `unwrap` create a new parser. Heading tails and reference definitions use a full parse to preserve heading IDs and links. Set `streaming` to `true` while content is being received, then `false` when done: diff --git a/examples/2.vite/svelte/src/pages/Syntax.svelte b/examples/2.vite/svelte/src/pages/Syntax.svelte index 8bf72abb..73bbc7d6 100644 --- a/examples/2.vite/svelte/src/pages/Syntax.svelte +++ b/examples/2.vite/svelte/src/pages/Syntax.svelte @@ -173,7 +173,7 @@ Text before the comment and text after the comment both render normally. diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 6e157e26..a6a2d098 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -299,6 +299,8 @@ function processBlockToken( ): { node: Node | null; nextIndex: number } { const token = tokens[startIndex] + if (token.type === 'reference') return { node: null, nextIndex: startIndex + 1 } + if (token.type === 'hr') { return { node: ['hr', {}] as Node, nextIndex: startIndex + 1 } } diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index ea538a4b..56ac8695 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -124,7 +124,7 @@ export function createMarkdownParser { + it.each([false, true])('omits reference definitions with streaming: %s', async (streaming) => { + const result = await createMarkdownParser()('# Heading\n\n[ref]: https://example.com\n\n[link][ref]', { streaming }) + expect(result.nodes).toMatchObject([ + ['h1', {}, 'Heading'], + ['p', {}, ['a', { href: 'https://example.com' }, 'link']], + ]) + }) + + it.each([ + ['duplicate headings', '# Same\n\nIntro\n\n# Same', '\n\nTail'], + ['nested headings', '## Parent\n\nIntro\n\n### Child', '\n\nTail'], + ['setext headings', 'Same\n====\n\nIntro\n\nSame\n====', '\n\nTail'], + ['CRLF setext headings', 'Same\r\n====\r\n\r\nIntro\r\n\r\nSame\r\n====', '\r\n\r\nTail'], + ['headings in lists', '# Same\n\nIntro\n\n- # Same', '\n\nTail'], + ['existing references', '[ref]: https://example.com\n\n# Heading\n\nIntro\n\n[link][ref]', ' grows'], + ['new references', '[link][ref]\n\nIntro\n\nTail', '\n\n[ref]: https://example.com'], + ])('matches a full parse after appending to %s', async (_, source, appended) => { + const parse = createMarkdownParser() + await parse(source, { streaming: true }) + + const result = await parse(source + appended, { streaming: true }) + + expect(result).toMatchObject(await createMarkdownParser()(source + appended)) + }) + describe('$.line metadata', () => { it('preserves position metadata on nodes in streaming mode', async () => { const parse = createMarkdownParser() diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 5e14daa9..47753ba1 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -65,7 +65,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/html": "15.7k (58 files)", "@comark/nuxt": "11.8k (58 files)", "@comark/react": "36.9k (74 files)", - "@comark/svelte": "44.9k (84 files)", + "@comark/svelte": "45.0k (84 files)", "@comark/vue": "51.7k (78 files)", "comark": "363k (156 files)", } From 9fabc1ad5133db7f7eb1c0c20036e069878a8743 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 11 Sep 2026 11:08:46 +0200 Subject: [PATCH 4/5] fix: reuse internal `createSerializedMarkdownParser` --- AGENTS.md | 1 - .../src/async/MarkdownAsync.svelte | 4 +- .../src/components/Markdown.svelte | 4 +- packages/comark-svelte/src/internal/parse.ts | 14 ---- packages/comark/src/parse.ts | 11 ++- packages/comark/src/utils/helpers.ts | 11 ++- .../test/utils/create-serialized-task.test.ts | 84 +++++++++++++++++++ 7 files changed, 104 insertions(+), 25 deletions(-) delete mode 100644 packages/comark-svelte/src/internal/parse.ts create mode 100644 packages/comark/test/utils/create-serialized-task.test.ts diff --git a/AGENTS.md b/AGENTS.md index e15031a7..0336235e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,7 +255,6 @@ packages/comark-svelte/ ├── src/ │ ├── index.ts # Entry point (@comark/svelte) │ ├── types.ts # Shared prop interfaces -│ ├── internal/parse.ts # Serialized incremental parser, preserving plugin errors │ ├── components/ │ │ ├── Markdown.svelte # High-level markdown → render ($state + $effect) │ │ ├── MarkdownDocument.svelte # Low-level AST → render component diff --git a/packages/comark-svelte/src/async/MarkdownAsync.svelte b/packages/comark-svelte/src/async/MarkdownAsync.svelte index 5334746c..0cadd04d 100644 --- a/packages/comark-svelte/src/async/MarkdownAsync.svelte +++ b/packages/comark-svelte/src/async/MarkdownAsync.svelte @@ -29,8 +29,8 @@ and wrap this component in a `` for pending/error states. -->