From e0945b427de4099c5ea12530f1900b28fc97703c Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 14 Sep 2026 22:52:13 +0530 Subject: [PATCH] fix(flowchart): prevent preview-source duplication; v4.9.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flowchart preview pane accumulated raw Mermaid source when the user fired several addNode mutations within the 250ms debounce window — mermaid.run({nodes:[div]}) is async, so the previous render's
(still carrying the source text) sat 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 carrying the source. Fixed by switching renderFlowChartMermaid (src/renderer.js) to replaceChildren() and adding a per-target WeakSet in-flight tracker so the new render always starts from a clean slate and the previous render's eventual innerHTML=svg is harmless on a detached node. Added a second regression test that fires 7 mutations inside the debounce with a renderMermaid mock that mimics mermaid.run's async innerHTML=svg closure. Amit Haridas --- README.md | 2 +- UPDATES.md | 8 +++ package.json | 2 +- src/renderer.js | 15 ++++- tests/flowchart-panel.test.js | 120 ++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 287d407..4adb8d9 100644 --- a/README.md +++ b/README.md @@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com) ## Version -v4.9.2 +v4.9.3 diff --git a/UPDATES.md b/UPDATES.md index 7325694..760e985 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,13 @@ # PanConverter - Updates & Changelog +## Version 4.9.3 (2026-09-14) + +### Fixes +- **Flowchart Panel — preview pane accumulated raw Mermaid source**: when the user fired several `addNode` mutations within the 250 ms preview debounce, `mermaid.run({ nodes: [div] })` is async, so the previous render's `
` (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', () => {