From 354a84c98a797bcd58801cb876e2f064f16e7864 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 22 Sep 2026 17:41:35 +0200 Subject: [PATCH] perf(svelte): reuse incremental parsing in streams --- docs/content/3.rendering/6.svelte.md | 2 + .../2.vite/svelte/src/pages/Syntax.svelte | 2 +- packages/comark-svelte/README.md | 2 + .../src/async/MarkdownAsync.svelte | 16 +- .../src/components/Markdown.svelte | 44 +++-- .../test/incremental-streaming.svelte.test.ts | 173 ++++++++++++++++++ .../test-components/MarkdownBoundary.svelte | 12 ++ packages/comark/src/parse.ts | 7 +- packages/comark/src/utils/helpers.ts | 11 +- .../test/utils/create-serialized-task.test.ts | 84 +++++++++ test/bundle.test.ts | 2 +- 11 files changed, 328 insertions(+), 27 deletions(-) create mode 100644 packages/comark-svelte/test/incremental-streaming.svelte.test.ts create mode 100644 packages/comark-svelte/test/test-components/MarkdownBoundary.svelte create mode 100644 packages/comark/test/utils/create-serialized-task.test.ts diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index a09a91024..01e508f25 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 8bf72abb1..73bbc7d61 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 090dcdd57..0cadd04d1 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 fdcffadf1..5e8ec4742 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 000000000..429655ce4 --- /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 000000000..0d11094c7 --- /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 fccc10e77..4630167f0 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 4f06893c0..eea45e241 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 000000000..9722aedb8 --- /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']) + }) +}) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index e733bd927..f41754ffb 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -65,7 +65,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/html": "16.5k (58 files)", "@comark/nuxt": "11.8k (58 files)", "@comark/react": "37.7k (76 files)", - "@comark/svelte": "44.9k (84 files)", + "@comark/svelte": "45.7k (84 files)", "@comark/vue": "56.0k (80 files)", "comark": "373k (160 files)", }