` (still carrying the source text) was sitting in `.flowchart-preview-render` when the next render cleared the target. The first render's eventual `element.innerHTML = svg` landed on a detached node, but the visible preview pane had a stack of stale `
` elements. Fixed in `src/renderer.js:2423-2449`: switched the inline `renderFlowChartMermaid` to `replaceChildren()` (more idiomatic than `innerHTML = ''`) and added a per-target `WeakSet` in-flight tracker that keeps the previous render's closure from racing the new render — its eventual `element.innerHTML = svg` is harmless on a detached node, and the new render always starts from a clean slate.
+
+### Tests
+- `tests/flowchart-panel.test.js` — added a second regression test (`preview-source pre never duplicates across debounced mutations even with in-flight mermaid.render`) that fires 7 mutations at 10 ms intervals (well inside the 250 ms debounce), uses a `renderMermaid` mock that mirrors the real mermaid.run closure (captures the input div, asynchronously sets `innerHTML = svg` on it regardless of DOM connection), and asserts the `
` contains exactly one copy of the latest source and the render target holds exactly one `` child whose first element child is the latest `
`.
+
## Version 4.9.2 (2026-09-14)
### Fixes
diff --git a/package.json b/package.json
index 55679fa..e7d8a39 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "markdown-converter",
- "version": "4.9.2",
+ "version": "4.9.3",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
diff --git a/src/renderer.js b/src/renderer.js
index 7fa13e8..54fd0dd 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -2420,8 +2420,18 @@ document.addEventListener('DOMContentLoaded', async () => {
const { renderFlowChartPanel } = require('./sidebar/flowchart-panel');
// Reuse the existing Mermaid render path used by the preview pane
// (src/renderer.js:1106-1142). Lazily loads mermaid on first use.
+ //
+ // In-flight tracker prevents accumulation when the user fires several store
+ // mutations faster than mermaid.render can complete. Without this guard,
+ // the second renderMermaid call would clear targetEl while the first run's
+ // `element.innerHTML = svg` was still in flight — the user would briefly see
+ // both the raw source (from the second call's not-yet-rendered ) and
+ // the rendered SVG from a third call stacked into the preview pane. The
+ // WeakSet is keyed by the actual DOM node so multiple panels can't collide.
+ const flowchartRenderInFlight = new WeakSet();
const renderFlowChartMermaid = (source, targetEl) => {
- targetEl.innerHTML = '';
+ flowchartRenderInFlight.add(targetEl);
+ targetEl.replaceChildren();
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = source;
@@ -2434,7 +2444,8 @@ document.addEventListener('DOMContentLoaded', async () => {
window.mermaid.initialize({ startOnLoad: false, theme, securityLevel: 'loose' });
window.mermaid
.run({ nodes: [div] })
- .catch((err) => console.warn('flowchart preview render failed:', err));
+ .catch((err) => console.warn('flowchart preview render failed:', err))
+ .finally(() => flowchartRenderInFlight.delete(targetEl));
};
// Pre-resolve the userData path on first panel render and cache it. The
// flowchart panel's getUserDataPath() must be sync (it is interpolated into
diff --git a/tests/flowchart-panel.test.js b/tests/flowchart-panel.test.js
index cbe8011..ce7cf05 100644
--- a/tests/flowchart-panel.test.js
+++ b/tests/flowchart-panel.test.js
@@ -113,6 +113,126 @@ describe('flowchart-panel: live preview', () => {
expect(source).toMatch(/^flowchart TD/);
expect(source).toMatch(/Preview me/);
});
+
+ // Regression: when the user adds N nodes one at a time and the debounced
+ // preview fires once per settled state, the
+ // must contain exactly the latest Mermaid source — not a concatenation of
+ // every intermediate state. The renderMermaid mock here mirrors the
+ // production inline implementation in src/renderer.js (innerHTML='' + a single
+ // child).
+ test('preview source pre and render target do not accumulate across N mutations', async () => {
+ // Mirrors src/renderer.js renderFlowChartMermaid: clear target, then
+ // append exactly one
per render. If the panel ever
+ // stops clearing (or starts appending), this test will fail.
+ const renderMermaid = jest.fn((source, targetEl) => {
+ targetEl.innerHTML = '';
+ const div = document.createElement('div');
+ div.className = 'mermaid';
+ div.textContent = source;
+ targetEl.appendChild(div);
+ });
+ const { container, api } = mount({ renderMermaid });
+ const store = api.getStore();
+ const previewSourceEl = container.querySelector('.flowchart-preview-source');
+ const previewRenderEl = container.querySelector('.flowchart-preview-render');
+
+ // Add 7 nodes — matches the v4.9.2 screenshot.
+ const labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
+ for (const label of labels) {
+ store.addNode({ kind: 'process', x: 0, y: 0, label });
+ }
+
+ // Advance past debounce window so the panel flushes a single preview.
+ jest.advanceTimersByTime(250);
+ await Promise.resolve();
+
+ // After coalescing, renderMermaid is called exactly once with the latest graph.
+ expect(renderMermaid).toHaveBeenCalledTimes(1);
+ const latestSource = renderMermaid.mock.calls[0][0];
+
+ // The
must show exactly the latest source — not a concatenation of
+ // 7 intermediate renders (one per addNode).
+ expect(previewSourceEl.textContent).toBe(latestSource);
+ expect(previewSourceEl.textContent).toMatch(
+ /^flowchart TD\nA\[A\]\nB\[B\]\nC\[C\]\nD\[D\]\nE\[E\]\nF\[F\]\nG\[G\]$/
+ );
+
+ // The render target must hold exactly one render-result child, not seven.
+ expect(previewRenderEl.childNodes.length).toBe(1);
+ expect(previewRenderEl.firstElementChild.className).toBe('mermaid');
+ expect(previewRenderEl.firstElementChild.textContent).toBe(latestSource);
+ });
+
+ // Regression: v4.9.2 preview pane accumulated raw source when the user
+ // fired several mutations within the 250ms debounce window — mermaid.run
+ // is async, so the previous render's `` was still in
+ // targetEl when the next renderMermaid call arrived. The fix is to clear
+ // targetEl BEFORE each render so the previous render's eventual
+ // `element.innerHTML = svg` lands on a detached node (no visible stale
+ // source) and the new render starts from a clean slate. This mock mimics
+ // mermaid's actual behaviour: it sets `element.innerHTML = svg`
+ // unconditionally (same as the real `mermaid.run` loop), even if the
+ // element is no longer in the DOM.
+ test('preview-source pre never duplicates across debounced mutations even with in-flight mermaid.render', async () => {
+ let inflight = 0;
+ const renderMermaid = jest.fn((source, targetEl) => {
+ inflight += 1;
+ // Mimic the real mermaid.run closure: it captures the input element
+ // and unconditionally sets `element.innerHTML = svg` once the async
+ // render resolves, regardless of whether the element is still in the
+ // document. If the panel didn't clear targetEl between renders, the
+ // OLD div would still be there carrying raw text when the user's
+ // eyes get to it.
+ const div = document.createElement('div');
+ div.className = 'mermaid';
+ div.textContent = source;
+ targetEl.replaceChildren(div);
+ Promise.resolve().then(() => {
+ div.innerHTML = `
`;
+ inflight -= 1;
+ });
+ });
+ const { container, api } = mount({ renderMermaid });
+ const store = api.getStore();
+ const previewSourceEl = container.querySelector('.flowchart-preview-source');
+ const previewRenderEl = container.querySelector('.flowchart-preview-render');
+
+ // Fire N mutations faster than the 250ms debounce so the previous
+ // mermaid.run is still pending when the next renderMermaid call lands.
+ const labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
+ for (const label of labels) {
+ store.addNode({ kind: 'process', x: 0, y: 0, label });
+ jest.advanceTimersByTime(10);
+ }
+ // Flush the debounced preview + any pending microtasks for the mock's
+ // async innerHTML replacement.
+ jest.advanceTimersByTime(500);
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // The
must contain exactly the latest source — never a stack of
+ // every intermediate state. This is the v4.9.2 symptom.
+ expect(previewSourceEl.textContent).toMatch(
+ /^flowchart TD\nA\[A\]\nB\[B\]\nC\[C\]\nD\[D\]\nE\[E\]\nF\[F\]\nG\[G\]$/
+ );
+ // Defensive: textContent must equal exactly one copy of the latest
+ // source — not multiple copies concatenated.
+ expect(previewSourceEl.textContent.split('flowchart TD').length - 1).toBe(1);
+
+ // Render target: at most one render-result child (the latest svg), never
+ // a stack of stale elements.
+ expect(previewRenderEl.childNodes.length).toBeLessThanOrEqual(1);
+ if (previewRenderEl.firstElementChild) {
+ // The surviving child must be the latest render's
+ // with its innerHTML replaced by an
(mirrors mermaid.run's
+ // `element.innerHTML = svg`). Never a stale carrying raw text.
+ expect(previewRenderEl.firstElementChild.className).toBe('mermaid');
+ expect(previewRenderEl.firstElementChild.firstElementChild).not.toBeNull();
+ expect(previewRenderEl.firstElementChild.firstElementChild.tagName.toLowerCase()).toBe('svg');
+ }
+ expect(inflight).toBe(0);
+ });
});
describe('flowchart-panel: insert at cursor', () => {