diff --git a/AGENTS.md b/AGENTS.md index 72d3d153f..7402393f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,7 +275,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 │ │ ├── Binding.tsx # Inline binding renderer │ │ ├── If.tsx # Conditional content renderer @@ -312,6 +312,8 @@ import math, { Math } from '@comark/react/plugins/math' import mermaid, { Mermaid } from '@comark/react/plugins/mermaid' ``` +React tests have two projects: `server` runs the Node/SSR tests, and `client` runs `*.browser.test.tsx` in headless Chromium. Install the browser with `pnpm exec playwright install chromium` before running the package tests. CI runs both projects. + ## Package: @comark/svelte Svelte 5 renderer for Comark. Located at `packages/comark-svelte/`: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71f42737b..0a897df21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,7 +76,7 @@ pnpm vitest run test/parse/sub-sup.test.ts Parser fixtures live in `packages/comark/SPEC` and run through `test/index.test.ts`. Parser changes should also preserve streaming behavior in `test/streaming.test.ts`. -Svelte's local tests include a headless Chromium project. Install the browser if needed: +React and Svelte tests include a headless Chromium project. Install the browser if needed: ```sh pnpm exec playwright install chromium diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index 0661f34f5..ab78ba124 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -637,7 +637,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 reuses completed blocks as text is appended. Keep `options` and `plugins` references stable between updates. Changing either, or `unwrap`, 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 0acd2fbee..43237f766 100644 --- a/packages/comark-react/README.md +++ b/packages/comark-react/README.md @@ -61,6 +61,8 @@ Heads up! ### Streaming +Streaming reuses completed blocks as text is appended. Keep parser options and plugins 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 5a05b21bc..e127917e4 100644 --- a/packages/comark-react/package.json +++ b/packages/comark-react/package.json @@ -61,6 +61,7 @@ "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@vitest/browser-playwright": "catalog:", "react": "catalog:", "react-dom": "catalog:", "vitest": "catalog:" diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 67d4b5fa4..0f957ef68 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -109,8 +109,8 @@ export interface MarkdownProps extends Omit export async function Markdown({ children, value, - options = {}, - plugins = [], + options, + plugins, unwrap = false, components: customComponents = {}, componentsManifest, @@ -146,7 +146,8 @@ export async function Markdown({ { + let parser: ReturnType | undefined + return (source: string, streaming: boolean): Promise => { + parser ??= createSerializedMarkdownParser({ ...options, ...(unwrap ? { unwrap } : {}), plugins }) + return parser(source, { streaming }) + } + }, [options, plugins, unwrap]) const parsePromise = useMemo( - () => - isMarkdownDocument(content) - ? Promise.resolve(content) - : parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), 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 — @@ -71,6 +73,7 @@ export function MarkdownClient({ ) diff --git a/packages/comark-react/src/index.ts b/packages/comark-react/src/index.ts index c45190557..c7017d6c1 100644 --- a/packages/comark-react/src/index.ts +++ b/packages/comark-react/src/index.ts @@ -59,13 +59,21 @@ export function defineMarkdownComponent(config: DefineMarkdownComponentOptions = ...parseOptions } = config + const optionsCache = new WeakMap() + const pluginsCache = new WeakMap, NonNullable>() + const configPlugins = config.plugins ?? [] + const MarkdownComponent: React.FC = (props) => { - const mergedOptions: Omit = { - ...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 new file mode 100644 index 000000000..60bb1a580 --- /dev/null +++ b/packages/comark-react/test/streaming.browser.test.tsx @@ -0,0 +1,283 @@ +import React, { act, Component, StrictMode } 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 +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.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 }) + 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') + }) +}) + +it('reuses the completed prefix in Strict Mode', async () => { + const probe = observe() + const value = 'First\n\nSecond\n\nTail' + const update = async (value: string): Promise => { + await act(async () => { + root.render( + + + + ) + }) + } + await update(value) + probe.inputs.length = 0 + await update(`${value} grows`) + expect(container.textContent).toBe('FirstSecondTail grows') + expect(probe.inputs.length).toBeGreaterThan(0) + expect(probe.inputs.every((input) => !input.includes('First'))).toBe(true) +}) + +it('keeps the newest configuration when an initial suspended parse finishes late', async () => { + const { promise, resolve } = Promise.withResolvers() + let started = false + let finished = false + await render({ + value: 'Old', + streaming: true, + plugins: [ + { + name: 'slow', + async pre() { + started = true + await promise + finished = true + }, + }, + ], + }) + expect(started).toBe(true) + await render({ value: 'New', streaming: true, plugins: [] }) + expect(container.textContent).toBe('New') + await act(async () => resolve()) + expect(finished).toBe(true) + expect(container.textContent).toBe('New') +}) + +it('serializes async post hooks and keeps the previous display while parsing', async () => { + const { promise, resolve } = Promise.withResolvers() + let active = 0 + let maxActive = 0 + const started: string[] = [] + const plugins: ComarkPlugin[] = [ + { + name: 'slow', + async post(state) { + active++ + maxActive = Math.max(active, maxActive) + started.push(state.markdown) + if (state.markdown === 'Slow') await promise + active-- + }, + }, + ] + await render({ value: 'Ready', streaming: true, plugins }) + expect(container.textContent).toBe('Ready') + await render({ value: 'Slow', streaming: true, plugins }) + expect(started).toContain('Slow') + expect(container.textContent).toBe('Ready') + await render({ value: 'Latest', streaming: true, plugins }) + await act(async () => resolve()) + expect(container.textContent).toBe('Latest') + expect(maxActive).toBe(1) +}) + +it('does not initialize tokenizer plugins for a pre-parsed document', async () => { + await render({ + value: { nodes: [['p', {}, 'Already parsed']], frontmatter: {}, meta: {} }, + plugins: [ + { + name: 'throwing-initializer', + markdownItPlugins: [ + () => { + throw new Error('Tokenizer initialized') + }, + ], + }, + ], + }) + expect(container.textContent).toBe('Already parsed') +}) diff --git a/packages/comark-react/vitest.config.ts b/packages/comark-react/vitest.config.ts index 78919820e..ee636b466 100644 --- a/packages/comark-react/vitest.config.ts +++ b/packages/comark-react/vitest.config.ts @@ -1,7 +1,27 @@ 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/pnpm-lock.yaml b/pnpm-lock.yaml index 4b3d64d40..25b28b666 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1593,6 +1593,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.3.0(@types/react@19.3.0) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 4.1.11(playwright@1.64.0)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))(vitest@4.1.11) react: specifier: 'catalog:' version: 19.3.0 diff --git a/test/bundle.test.ts b/test/bundle.test.ts index f70e6b6fc..85dd671b6 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -64,7 +64,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/ansi": "37.3k (98 files)", "@comark/html": "16.5k (58 files)", "@comark/nuxt": "11.9k (58 files)", - "@comark/react": "38.6k (76 files)", + "@comark/react": "39.6k (76 files)", "@comark/svelte": "46.8k (84 files)", "@comark/vue": "56.4k (80 files)", "comark": "389k (152 files)",