Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/content/3.rendering/5.react.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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:

```tsx [components/AiChat.tsx]
import { useState } from 'react'
Expand Down
2 changes: 2 additions & 0 deletions packages/comark-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Heading tails and reference definitions use a full parse to preserve heading IDs and links.

```tsx
<Markdown streaming={isStreaming} caret>
{content}
Expand Down
1 change: 1 addition & 0 deletions packages/comark-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
7 changes: 4 additions & 3 deletions packages/comark-react/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export interface MarkdownProps {
export async function Markdown({
children,
value,
options = {},
plugins = [],
options,
plugins,
unwrap = false,
components: customComponents = {},
componentsManifest,
Expand Down Expand Up @@ -139,7 +139,8 @@ export async function Markdown({
return (
<MarkdownClient
value={source}
options={parseOptions}
options={options}
unwrap={unwrap}
plugins={plugins}
components={customComponents}
componentsManifest={componentsManifest}
Expand Down
36 changes: 29 additions & 7 deletions packages/comark-react/src/components/MarkdownClient.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { use, useDeferredValue, useMemo, Suspense } from 'react'
import { parseMarkdown } from 'comark'
import { createMarkdownParser } from 'comark'
import type { MarkdownDocument as MarkdownDocumentType } from 'comark'
import { isMarkdownDocument } from 'comark/utils'
import { MarkdownLive } from './MarkdownLive.tsx'
Expand Down Expand Up @@ -35,19 +35,40 @@ function MarkdownContent({
)
}

export function MarkdownClient({ children, value, options = {}, plugins = [], ...rest }: MarkdownProps) {
export function MarkdownClient({
children,
value,
options,
plugins,
unwrap = false,
streaming = false,
...rest
}: MarkdownProps) {
const content = isMarkdownDocument(value)
? value
: children
? String(children)
: ((value as string | undefined) ?? '')

// 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 parse = useMemo(() => {
let parser: ReturnType<typeof createMarkdownParser> | undefined
let pending: Promise<unknown> = 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 —
Expand All @@ -59,6 +80,7 @@ export function MarkdownClient({ children, value, options = {}, plugins = [], ..
<MarkdownContent
parsePromise={deferredPromise}
{...rest}
streaming={streaming}
/>
</Suspense>
)
Expand Down
19 changes: 14 additions & 5 deletions packages/comark-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParserOptions, ParserOptions>()
const pluginsCache = new WeakMap<NonNullable<MarkdownProps['plugins']>, NonNullable<MarkdownProps['plugins']>>()
const configPlugins = config.plugins ?? []

const MarkdownComponent: React.FC<MarkdownProps> = (props) => {
const mergedOptions: Exclude<ParserOptions, 'plugins'> = {
...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,
Expand Down
188 changes: 188 additions & 0 deletions packages/comark-react/test/streaming.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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'
import { defineMarkdownComponent } from '../src/index'

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })

let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
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(<MarkdownClient {...props} />)
})
}
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.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<MarkdownProps, 'options' | 'plugins'> = props) {
await act(async () =>
root.render(
<Defined
value={value}
streaming
{...config}
/>
)
)
}
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 })
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<void>()
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 ? <p>Parse failed</p> : this.props.children
}
}
const plugins: ComarkPlugin[] = [
{
name: 'failure',
pre() {
throw new Error('plugin failed')
},
},
]
await act(async () => {
root.render(
<Boundary>
<MarkdownClient
value="Text"
streaming
plugins={plugins}
/>
</Boundary>
)
})
expect(container.textContent).toBe('Parse failed')
})
})
16 changes: 15 additions & 1 deletion packages/comark-react/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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 }],
},
},
},
],
},
})
2 changes: 2 additions & 0 deletions packages/comark/src/internal/parse/token-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
Loading
Loading