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) + }) +}) 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)", } `) })