diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index a09a9102..01e508f2 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. 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: ```svelte [components/AiChat.svelte] 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-svelte/src/async/MarkdownAsync.svelte b/packages/comark-svelte/src/async/MarkdownAsync.svelte index 090dcdd5..0cadd04d 100644 --- a/packages/comark-svelte/src/async/MarkdownAsync.svelte +++ b/packages/comark-svelte/src/async/MarkdownAsync.svelte @@ -29,7 +29,7 @@ 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..5e8ec474 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -25,7 +25,7 @@ This is an alert component --> 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..429655ce --- /dev/null +++ b/packages/comark-svelte/test/incremental-streaming.svelte.test.ts @@ -0,0 +1,173 @@ +import type { ComarkPlugin } 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('ignores old plugin results after a configuration change', async () => { + const { promise: gate, resolve: release } = Promise.withResolvers() + 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 () => { + const { promise: gate, resolve: release } = Promise.withResolvers() + 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 parser errors to the Svelte boundary', async () => { + const screen = await render(MarkdownBoundary, { + component: Markdown, + 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 () => { + const { promise: first, resolve: releaseFirst } = Promise.withResolvers() + const { promise: last, resolve: releaseLast } = Promise.withResolvers() + 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 7869b489..b3f7c30e 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -277,8 +277,9 @@ export async function parseMarkdown[] = []>( diff --git a/packages/comark/src/utils/helpers.ts b/packages/comark/src/utils/helpers.ts index 4f06893c..eea45e24 100644 --- a/packages/comark/src/utils/helpers.ts +++ b/packages/comark/src/utils/helpers.ts @@ -3,14 +3,19 @@ import type { ComarkPlugin, ComarkPluginFactory } from '../types.ts' /** * Returns a function that invokes `fn` **strictly one at a time**: each call waits until the * previous invocation has settled (resolved or rejected) before starting the next. + * Callers still receive the real result or rejection — failures are not swallowed. */ export function createSerializedTask( fn: (...args: TArgs) => Promise ): (...args: TArgs) => Promise { - let chain: Promise = Promise.resolve(null as TResult) + // Keep the queue moving after either settle so stream state stays ordered, + // but return the real result/rejection to callers (Vue/Svelte boundaries). + let pending: Promise = Promise.resolve() return (...args: TArgs) => { - chain = chain.then(() => fn(...args)).catch(() => null as TResult) - return chain + const run = () => fn(...args) + const result = pending.then(run, run) + pending = result + return result } } diff --git a/packages/comark/test/utils/create-serialized-task.test.ts b/packages/comark/test/utils/create-serialized-task.test.ts new file mode 100644 index 00000000..9722aedb --- /dev/null +++ b/packages/comark/test/utils/create-serialized-task.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ComarkPlugin } from '../../src/types' +import { createSerializedMarkdownParser } from '../../src/parse' +import { createSerializedTask } from '../../src/utils/helpers' + +describe('createSerializedTask', () => { + it('runs tasks one at a time and preserves results', async () => { + let active = 0 + let maxActive = 0 + const { promise: gate, resolve: release } = Promise.withResolvers() + + const run = createSerializedTask(async (label: string) => { + active++ + maxActive = Math.max(maxActive, active) + if (label === 'slow') await gate + active-- + return label + }) + + const first = run('slow') + const second = run('fast') + release() + + await expect(first).resolves.toBe('slow') + await expect(second).resolves.toBe('fast') + expect(maxActive).toBe(1) + }) + + it('propagates rejections without breaking the queue', async () => { + const run = createSerializedTask(async (shouldFail: boolean) => { + if (shouldFail) throw new Error('boom') + return 'ok' + }) + + await expect(run(true)).rejects.toThrow('boom') + await expect(run(false)).resolves.toBe('ok') + }) +}) + +describe('createSerializedMarkdownParser', () => { + it('propagates plugin errors and keeps the queue usable', async () => { + let shouldFail = true + const failPlugin: ComarkPlugin = { + name: 'maybe-fail', + async pre() { + if (shouldFail) throw new Error('Plugin failed') + }, + } + const parse = createSerializedMarkdownParser({ + plugins: [failPlugin], + }) + + await expect(parse('hello')).rejects.toThrow('Plugin failed') + shouldFail = false + await expect(parse('# ok')).resolves.toMatchObject({ + nodes: [['h1', expect.any(Object), 'ok']], + }) + }) + + it('serializes overlapping stream updates', async () => { + const order: string[] = [] + const { promise: gate, resolve: release } = Promise.withResolvers() + const orderPlugin: ComarkPlugin = { + name: 'order', + async pre(state) { + order.push(`start:${state.markdown}`) + if (state.markdown === 'Slow') await gate + order.push(`end:${state.markdown}`) + }, + } + const parse = createSerializedMarkdownParser({ + plugins: [orderPlugin], + }) + + const first = parse('Slow', { streaming: true }) + const second = parse('Fast', { streaming: true }) + // Second must not start until first finishes. + await vi.waitFor(() => expect(order).toEqual(['start:Slow'])) + release() + await first + await second + expect(order).toEqual(['start:Slow', 'end:Slow', 'start:Fast', 'end:Fast']) + }) +})