From 12d6c06bd5a3fee63e5c7692429d8b22cde22980 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 12:34:46 +0200 Subject: [PATCH 1/2] perf(react): reuse incremental parsing in streams --- 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 +++- .../test/streaming.browser.test.tsx | 155 ++++++++++++++++++ packages/comark-react/vitest.config.ts | 16 +- packages/comark/src/parse.ts | 3 +- packages/comark/test/streaming.test.ts | 12 ++ pnpm-lock.yaml | 3 + test/bundle.test.ts | 2 +- 12 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 packages/comark-react/test/streaming.browser.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 0336235e..0da64d1d 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 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/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..7b00ca4d 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:", + "@vitest/browser-playwright": "catalog:", "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.browser.test.tsx b/packages/comark-react/test/streaming.browser.test.tsx new file mode 100644 index 00000000..d8d8e4a0 --- /dev/null +++ b/packages/comark-react/test/streaming.browser.test.tsx @@ -0,0 +1,155 @@ +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('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 () => { + const { promise: gate, resolve: release } = Promise.withResolvers() + 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('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-react/vitest.config.ts b/packages/comark-react/vitest.config.ts index 78919820..70943112 100644 --- a/packages/comark-react/vitest.config.ts +++ b/packages/comark-react/vitest.config.ts @@ -1,7 +1,21 @@ import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' export default defineConfig({ test: { - include: ['test/**/*.test.{ts,tsx}'], + projects: [ + { test: { name: 'server', include: ['test/**/*.test.{ts,tsx}'], exclude: ['test/**/*.browser.test.tsx'] } }, + { + test: { + name: 'client', + include: ['test/**/*.browser.test.tsx'], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }], + }, + }, + }, + ], }, }) 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..b8e09188 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1395,6 +1395,9 @@ importers: specifier: 'catalog:' version: 4.3.1 devDependencies: + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 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) '@types/react': specifier: 'catalog:' version: 19.2.17 diff --git a/test/bundle.test.ts b/test/bundle.test.ts index c0dc230e..5ab298aa 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": "36.9k (74 files)", + "@comark/react": "37.6k (74 files)", "@comark/svelte": "43.9k (82 files)", "@comark/vue": "51.7k (78 files)", "comark": "363k (156 files)", From 1a793ab61503ac8a82a61dbcce99c8323e708a7c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 5 Sep 2026 13:04:19 +0200 Subject: [PATCH 2/2] fix(react): preserve factory parsers and streamed links --- docs/content/3.rendering/5.react.md | 2 +- packages/comark-react/README.md | 2 +- packages/comark-react/src/index.ts | 19 ++++++++--- .../test/streaming.browser.test.tsx | 33 +++++++++++++++++++ .../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 +- 8 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index 5d22b488..bf021f01 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -635,7 +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. +The client component reuses completed blocks while text is appended. Keep `options` and `plugins` references stable between updates. Replacing either creates 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. The final update parses the complete document: diff --git a/packages/comark-react/README.md b/packages/comark-react/README.md index b3adb01c..1ccc09ce 100644 --- a/packages/comark-react/README.md +++ b/packages/comark-react/README.md @@ -61,7 +61,7 @@ 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. +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. Heading tails and reference definitions use a full parse to preserve heading IDs and links. ```tsx diff --git a/packages/comark-react/src/index.ts b/packages/comark-react/src/index.ts index c6e4c994..b946800e 100644 --- a/packages/comark-react/src/index.ts +++ b/packages/comark-react/src/index.ts @@ -59,13 +59,22 @@ export function defineMarkdownComponent(config: DefineMarkdownComponentOptions = ...parseOptions } = config + // Keep parser inputs stable across renders without client-only hooks. + const optionsCache = new WeakMap() + const pluginsCache = new WeakMap, NonNullable>() + const configPlugins = config.plugins ?? [] + const MarkdownComponent: React.FC = (props) => { - const mergedOptions: Exclude = { - ...parseOptions, - ...props.options, + let mergedOptions = parseOptions + if (props.options) { + mergedOptions = optionsCache.get(props.options) ?? { ...parseOptions, ...props.options } + optionsCache.set(props.options, mergedOptions) + } + let mergedPlugins = configPlugins + if (props.plugins) { + mergedPlugins = pluginsCache.get(props.plugins) ?? [...configPlugins, ...props.plugins] + pluginsCache.set(props.plugins, mergedPlugins) } - - const mergedPlugins = [...(config.plugins || []), ...(props.plugins || [])] const mergedComponents = { ...configComponents, diff --git a/packages/comark-react/test/streaming.browser.test.tsx b/packages/comark-react/test/streaming.browser.test.tsx index d8d8e4a0..3de90431 100644 --- a/packages/comark-react/test/streaming.browser.test.tsx +++ b/packages/comark-react/test/streaming.browser.test.tsx @@ -5,6 +5,7 @@ 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' +import { defineMarkdownComponent } from '../src/index' Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) @@ -84,6 +85,38 @@ describe('MarkdownClient streaming', () => { expect(container.querySelector('h1')?.id).toBe('heading') }) + it.each([false, true])('reuses factory configuration with caller overrides: %s', async (override) => { + const probe = observe() + const Base = defineMarkdownComponent({ extends: MarkdownClient, plugins: probe.plugins, headingIds: false }) + const Defined = defineMarkdownComponent({ extends: Base }) + const props = override ? { options: { headingIds: true }, plugins: [{ name: 'caller' }] } : {} + const first = '# Completed\n\nFirst paragraph.\n\nLast paragraph' + async function update(value: string, config: Pick = props) { + await act(async () => + root.render( + + ) + ) + } + await update(first) + expect(container.querySelector('h1')?.hasAttribute('id')).toBe(override) + await update(first + ' grows') + expect(probe.inputs.at(-1)).not.toContain('# Completed') + expect(container.textContent).toContain('Last paragraph grows') + + await update(first + ' grows', { ...props, options: { headingIds: !override } }) + expect(probe.inputs.at(-1)).toBe(first + ' grows') + expect(container.querySelector('h1')?.hasAttribute('id')).toBe(!override) + const replacement = observe() + replacement.plugins[0].name = 'replacement' + await update(first + ' grows', { ...props, plugins: replacement.plugins }) + expect(replacement.inputs).toEqual([first + ' grows']) + }) + it('applies unwrap changes and bypasses parsing for documents', async () => { const probe = observe() await render({ value: 'Paragraph', plugins: probe.plugins }) 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 5ab298aa..8b858762 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": "38.2k (74 files)", "@comark/svelte": "43.9k (82 files)", "@comark/vue": "51.7k (78 files)", "comark": "363k (156 files)",