From ee9e0cc2f556b1cd112d57bce55698531b48f8ee Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 10:41:02 +0200 Subject: [PATCH 1/7] 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/7] 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)", } `) }) From b21fa0bbb13c11f52deb0227f5f1130f2eafd330 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 11:11:56 +0200 Subject: [PATCH 3/7] chore: trim the parse failure fix to the necessary changes --- .../src/components/markdown.component.ts | 1 - packages/comark-vue/src/components/Markdown.ts | 1 - packages/comark-vue/test/parse-error.test.ts | 5 ----- packages/comark/src/utils/helpers.ts | 1 - packages/comark/test/serialized-task.test.ts | 18 ------------------ 5 files changed, 26 deletions(-) diff --git a/packages/comark-angular/src/components/markdown.component.ts b/packages/comark-angular/src/components/markdown.component.ts index 51badc7e..7a4abb5f 100644 --- a/packages/comark-angular/src/components/markdown.component.ts +++ b/packages/comark-angular/src/components/markdown.component.ts @@ -116,7 +116,6 @@ export class Markdown implements OnChanges { 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-vue/src/components/Markdown.ts b/packages/comark-vue/src/components/Markdown.ts index 9edac90b..138d9246 100644 --- a/packages/comark-vue/src/components/Markdown.ts +++ b/packages/comark-vue/src/components/Markdown.ts @@ -239,7 +239,6 @@ export const Markdown: MarkdownComponent = defineComponent({ if (isMarkdownDocument(props.value)) return 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 index c9408e86..d4858f0b 100644 --- a/packages/comark-vue/test/parse-error.test.ts +++ b/packages/comark-vue/test/parse-error.test.ts @@ -4,11 +4,6 @@ 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 = { diff --git a/packages/comark/src/utils/helpers.ts b/packages/comark/src/utils/helpers.ts index 29f7c781..b6895376 100644 --- a/packages/comark/src/utils/helpers.ts +++ b/packages/comark/src/utils/helpers.ts @@ -12,7 +12,6 @@ export function createSerializedTask( let chain: Promise = Promise.resolve() return (...args: TArgs) => { 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 index 1dcb593f..7dd2445f 100644 --- a/packages/comark/test/serialized-task.test.ts +++ b/packages/comark/test/serialized-task.test.ts @@ -1,25 +1,7 @@ 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') From 8b0e9477b659149cd0e2df36b0121e8e4be9b277 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 11:11:57 +0200 Subject: [PATCH 4/7] fix(svelte): drop a stale parse result after a newer one rejects --- packages/comark-svelte/src/components/Markdown.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/comark-svelte/src/components/Markdown.svelte b/packages/comark-svelte/src/components/Markdown.svelte index c0171e1b..50ecbcd5 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -72,8 +72,10 @@ This is an alert component parsed = result } }) - // Keep the last good document rendered and report the failure. - .catch((error) => console.error('[comark] failed to parse markdown', error)) + .catch((error) => { + if (currentVersion > appliedVersion) appliedVersion = currentVersion + console.error('[comark] failed to parse markdown', error) + }) }) From a559f08a215ba42d53dcf21c166cce3702846fb2 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 11:11:57 +0200 Subject: [PATCH 5/7] chore: refresh the bundle size snapshot --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index e974ff08..76efbd52 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -65,7 +65,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/html": "15.7k (58 files)", "@comark/nuxt": "11.8k (58 files)", "@comark/react": "36.8k (74 files)", - "@comark/svelte": "44.0k (82 files)", + "@comark/svelte": "44.1k (82 files)", "@comark/vue": "54.8k (78 files)", "comark": "365k (158 files)", } From af469f1354bb338015bc5038e68a7bc17f0fdc69 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 11:19:55 +0200 Subject: [PATCH 6/7] test(svelte): drop a stale parse after a newer one rejects --- .../test/streaming.svelte.test.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/comark-svelte/test/streaming.svelte.test.ts b/packages/comark-svelte/test/streaming.svelte.test.ts index a4867572..c3b32623 100644 --- a/packages/comark-svelte/test/streaming.svelte.test.ts +++ b/packages/comark-svelte/test/streaming.svelte.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { parseMarkdown } from 'comark' +import type { ComarkPlugin } from 'comark' import Markdown from '../src/components/Markdown.svelte' import MarkdownDocument from '../src/components/MarkdownDocument.svelte' import Alert from './test-components/Alert.svelte' @@ -186,3 +187,29 @@ describe('streaming with MarkdownDocument', () => { await expect.element(screen.getByText('Second')).toBeInTheDocument() }) }) + +describe('parse failures', () => { + it('ignores a stale parse when a newer one rejects', async () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const slow: ComarkPlugin = { name: 'slow', post: () => gate } + const failing: ComarkPlugin = { + name: 'failing', + post() { + throw new Error('plugin exploded') + }, + } + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const screen = await render(Markdown, { value: 'Good' }) + await expect.element(screen.getByText('Good')).toBeInTheDocument() + + await screen.rerender({ value: 'Stale', plugins: [slow] }) + await screen.rerender({ value: 'Newer', plugins: [failing] }) + release() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(screen.container.textContent).toContain('Good') + expect(screen.container.textContent).not.toContain('Stale') + }) +}) From f80b24f5821246baa3c7638a0291f39c668831b2 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 15 Sep 2026 12:04:34 +0200 Subject: [PATCH 7/7] chore: refresh the 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 a6534737..d2b110c4 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": "56.2k (72 files)", + "@comark/angular": "56.3k (72 files)", "@comark/ansi": "37.3k (98 files)", "@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/vue": "56.0k (80 files)", - "comark": "368k (158 files)", + "@comark/svelte": "45.1k (84 files)", + "@comark/vue": "56.1k (80 files)", + "comark": "369k (158 files)", } `) })