/**
* MermaidDiagram's render effect re-runs on every `chart` change, and during
* STREAMING the chart text grows chunk by chunk — so several renders are in
* flight at once. Without a per-effect cancellation guard a slower EARLIER
* render can resolve last and overwrite the newer output (and, on the error
* path, paint an error for a chart that is no longer displayed).
*
* The 15s render timeout does not close this: it rejects the race, but does
* not abort the underlying `mermaid.render`, so the stale write is merely
* bounded, not prevented. These fixtures pin the guard.
*
* `mermaid` is mocked with a hand-settled deferred so resolution ORDER is
* under the test's control. (The real-mermaid security fixture lives in
* ./mermaid-security.test.ts and must stay unmocked, hence a separate file.)
*/
import { render, act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface Deferred {
chart: string
resolve: (value: { svg: string }) => void
reject: (err: Error) => void
}
const renders: Deferred[] = []
vi.mock('mermaid', () => ({
default: {
initialize: vi.fn(),
render: vi.fn(
(_id: string, chart: string) =>
new Promise<{ svg: string }>((resolve, reject) => {
renders.push({ chart, resolve, reject })
}),
),
},
}))
import { MermaidDiagram } from '../mermaid-diagram'
/**
* Let the mocked dynamic import + the resolved promise chain flush.
*
* MACROTASK-based and BOUNDED-POLLING on purpose. A fixed number of
* `await Promise.resolve()` turns is NOT a settle condition here: the component
* reaches `mermaid.render` through `mount effect → setMounted → re-render →
* effect → await import('mermaid')`, and how many microtask turns a dynamic
* import takes to resolve differs across Node/vitest releases. Three turns
* happened to be enough on Node 22 and are NOT on Node 25, where the second
* `mermaid.render` had not been called yet when the assertions ran — the suite
* failed with `renders.length === 1` (and, once the real module leaked in, with
* a genuine mermaid parse error). Poll for the condition instead of guessing a
* tick count.
*/
const flushUntil = async (done: () => boolean, what: string) => {
for (let i = 0; i < 50; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
if (done()) return
}
throw new Error(`timed out waiting for ${what}`)
}
/** Settle pending state updates when there is no specific condition to await. */
const flush = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
/** Wait until `mermaid.render` has been called exactly `n` times. */
const flushRenders = (n: number) =>
flushUntil(() => renders.length >= n, `${n} mermaid.render call(s)`)
const svgOf = (container: HTMLElement) =>
container.querySelector('.mermaid-svg-container')?.innerHTML ?? ''
describe('MermaidDiagram stale-render guard', () => {
beforeEach(() => {
renders.length = 0
})
it('ignores an earlier render that resolves after a newer chart', async () => {
const view = render()
await flushRenders(1)
view.rerender()
await flushRenders(2)
// Newer render lands first, then the abandoned earlier one.
await act(async () => {
renders[1]!.resolve({ svg: '' })
})
await act(async () => {
renders[0]!.resolve({ svg: '' })
})
await flush()
expect(svgOf(view.container)).toContain('id="new"')
expect(svgOf(view.container)).not.toContain('id="stale"')
})
it('ignores a late failure from an abandoned render', async () => {
const view = render()
await flushRenders(1)
view.rerender()
await flushRenders(2)
await act(async () => {
renders[1]!.resolve({ svg: '' })
})
await act(async () => {
renders[0]!.reject(new Error('Diagram rendering timed out after 15000ms'))
})
await flush()
expect(view.container.textContent).not.toContain('Diagram Error')
expect(svgOf(view.container)).toContain('id="new"')
})
/**
* The error state must not be STICKY. This is the streaming hot path, not an
* exotic case: the chart text grows chunk by chunk, every intermediate
* prefix is an invalid diagram, and mermaid rejects each one. The render body
* evaluates `error` BEFORE `svg`, so an uncleared message keeps the "Diagram
* Error" card on screen for the component's whole lifetime — the finished
* diagram never appears even though its render succeeded.
*/
it('recovers from a transient failure once a later render succeeds', async () => {
const view = render()
await flushRenders(1)
// Partial chart → parse error, exactly what a mid-stream prefix produces.
await act(async () => {
renders[0]!.reject(new Error('Parse error on line 1'))
})
await flush()
expect(view.container.textContent).toContain('Diagram Error')
// The next delta completes the chart and renders cleanly.
view.rerender()
await flushRenders(2)
await act(async () => {
renders[1]!.resolve({ svg: '' })
})
await flush()
expect(view.container.textContent).not.toContain('Diagram Error')
expect(svgOf(view.container)).toContain('id="settled"')
})
})