From ee9e0cc2f556b1cd112d57bce55698531b48f8ee Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 10:41:02 +0200 Subject: [PATCH 1/2] fix(comark): surface parse failures instead of rendering an empty document The serialized task queue caught every rejection and resolved to null, so a failed parse rendered an empty document and nothing reached the console. Each call now sees its own rejection while the queue keeps accepting later calls. The initial parse propagates so Suspense, an error boundary or onErrorCaptured can handle it. Later parses in the Vue watcher, Angular ngOnChanges and the Svelte effect log the error and keep the last good document on screen. --- .../src/components/markdown.component.ts | 11 +++-- .../src/components/Markdown.svelte | 15 ++++--- .../comark-vue/src/components/Markdown.ts | 5 ++- packages/comark-vue/test/parse-error.test.ts | 38 +++++++++++++++++ packages/comark/src/utils/helpers.ts | 10 +++-- packages/comark/test/serialized-task.test.ts | 42 +++++++++++++++++++ 6 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 packages/comark-vue/test/parse-error.test.ts create mode 100644 packages/comark/test/serialized-task.test.ts diff --git a/packages/comark-angular/src/components/markdown.component.ts b/packages/comark-angular/src/components/markdown.component.ts index c15571b2..51badc7e 100644 --- a/packages/comark-angular/src/components/markdown.component.ts +++ b/packages/comark-angular/src/components/markdown.component.ts @@ -111,9 +111,12 @@ export class Markdown implements OnChanges { } source = source.trim() - this.serializedParse(source, { streaming: this.streaming }).then((result) => { - this.document = result - this.cdr.markForCheck() - }) + this.serializedParse(source, { streaming: this.streaming }) + .then((result) => { + this.document = result + this.cdr.markForCheck() + }) + // Keep the last good document rendered and report the failure. + .catch((error: unknown) => console.error('[comark] failed to parse markdown', error)) } } diff --git a/packages/comark-svelte/src/components/Markdown.svelte b/packages/comark-svelte/src/components/Markdown.svelte index fdcffadf..c0171e1b 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -65,12 +65,15 @@ This is an alert component // `parse` directly mutates `plugins` which creates an infinite effect loop // so we copy it before passing it in so it gets a regular JS array and we get to still // track dependencies from an external perspective - parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }).then((result) => { - if (currentVersion > appliedVersion) { - appliedVersion = currentVersion - parsed = result - } - }) + parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }) + .then((result) => { + if (currentVersion > appliedVersion) { + appliedVersion = currentVersion + parsed = result + } + }) + // Keep the last good document rendered and report the failure. + .catch((error) => console.error('[comark] failed to parse markdown', error)) }) diff --git a/packages/comark-vue/src/components/Markdown.ts b/packages/comark-vue/src/components/Markdown.ts index 202c19e8..9edac90b 100644 --- a/packages/comark-vue/src/components/Markdown.ts +++ b/packages/comark-vue/src/components/Markdown.ts @@ -237,7 +237,10 @@ export const Markdown: MarkdownComponent = defineComponent({ () => [markdown.value, props.streaming] as const, () => { if (isMarkdownDocument(props.value)) return - parse(markdown.value, { streaming: props.streaming }).then((result) => (parsed.value = result)) + parse(markdown.value, { streaming: props.streaming }) + .then((result) => (parsed.value = result)) + // Keep the last good document rendered and report the failure. + .catch((error) => console.error('[comark] failed to parse markdown', error)) } ) diff --git a/packages/comark-vue/test/parse-error.test.ts b/packages/comark-vue/test/parse-error.test.ts new file mode 100644 index 00000000..c9408e86 --- /dev/null +++ b/packages/comark-vue/test/parse-error.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { createSSRApp, h, onErrorCaptured } from 'vue' +import { renderToString } from '@vue/server-renderer' +import type { ComarkPlugin } from 'comark' +import { Markdown } from '../src/components/Markdown.ts' + +/** + * A failing parse used to resolve to `null` and render an empty document, which + * hid the error from the app. The initial parse now rejects, so the failure + * reaches `onErrorCaptured` instead of being rendered as empty content. + */ +describe('Markdown parse errors', () => { + it('surfaces an initial parse failure instead of rendering an empty document', async () => { + const failing: ComarkPlugin = { + name: 'failing', + post() { + throw new Error('plugin exploded') + }, + } + + const captured: unknown[] = [] + const app = createSSRApp({ + setup() { + onErrorCaptured((error) => { + captured.push(error) + return false + }) + return () => h(Markdown, { value: '# Hello', plugins: [failing] }) + }, + }) + + const html = await renderToString(app as any) + + expect(captured).toHaveLength(1) + expect((captured[0] as Error).message).toBe('plugin exploded') + expect(html).not.toContain('comark-content') + }) +}) diff --git a/packages/comark/src/utils/helpers.ts b/packages/comark/src/utils/helpers.ts index 4f06893c..29f7c781 100644 --- a/packages/comark/src/utils/helpers.ts +++ b/packages/comark/src/utils/helpers.ts @@ -3,14 +3,18 @@ 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. + * + * A rejection is handed to the caller that triggered it, and the queue keeps accepting calls. */ export function createSerializedTask( fn: (...args: TArgs) => Promise ): (...args: TArgs) => Promise { - let chain: Promise = Promise.resolve(null as TResult) + let chain: Promise = Promise.resolve() return (...args: TArgs) => { - chain = chain.then(() => fn(...args)).catch(() => null as TResult) - return chain + const result = chain.then(() => fn(...args)) + // Keep the queue alive after a failure, but let this caller see it. + chain = result.catch(() => undefined) + return result } } diff --git a/packages/comark/test/serialized-task.test.ts b/packages/comark/test/serialized-task.test.ts new file mode 100644 index 00000000..1dcb593f --- /dev/null +++ b/packages/comark/test/serialized-task.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { createSerializedTask } from '../src/utils/helpers.ts' + +const tick = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('createSerializedTask', () => { + it('runs calls strictly one at a time', async () => { + const order: string[] = [] + const task = createSerializedTask(async (name: string, delay: number) => { + await tick(delay) + order.push(name) + return name + }) + + const slow = task('slow', 20) + const fast = task('fast', 0) + + await Promise.all([slow, fast]) + + expect(order).toEqual(['slow', 'fast']) + }) + + it('rejects the caller instead of resolving null', async () => { + const task = createSerializedTask(async () => { + throw new Error('boom') + }) + + await expect(task()).rejects.toThrow('boom') + }) + + it('keeps running after a rejection', async () => { + let calls = 0 + const task = createSerializedTask(async () => { + calls++ + if (calls === 1) throw new Error('boom') + return calls + }) + + await expect(task()).rejects.toThrow('boom') + await expect(task()).resolves.toBe(2) + }) +}) From bd19e8833d144b7614715da47a88e282568c309a Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 10:41:06 +0200 Subject: [PATCH 2/2] test: update bundle size snapshot --- test/bundle.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 6513ed58..e974ff08 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -60,14 +60,14 @@ describe('package bundle size', { timeout: 60_000 }, () => { expect(report).toMatchInlineSnapshot(` { - "@comark/angular": "54.0k (70 files)", + "@comark/angular": "54.1k (70 files)", "@comark/ansi": "36.6k (98 files)", "@comark/html": "15.7k (58 files)", "@comark/nuxt": "11.8k (58 files)", "@comark/react": "36.8k (74 files)", - "@comark/svelte": "43.9k (82 files)", - "@comark/vue": "54.7k (78 files)", - "comark": "364k (158 files)", + "@comark/svelte": "44.0k (82 files)", + "@comark/vue": "54.8k (78 files)", + "comark": "365k (158 files)", } `) })