fix(flowchart): prevent preview-source duplication; v4.9.3

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
<div class="mermaid"> (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 <div class="mermaid">
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
This commit is contained in:
2026-09-14 22:52:13 +05:30
parent ac31162d3b
commit e0945b427d
5 changed files with 143 additions and 4 deletions
+1 -1
View File
@@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v4.9.2 v4.9.3
+8
View File
@@ -1,5 +1,13 @@
# PanConverter - Updates & Changelog # 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 `<div class="mermaid">` (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 `<div class="mermaid">` 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 `<pre>` contains exactly one copy of the latest source and the render target holds exactly one `<div class="mermaid">` child whose first element child is the latest `<svg>`.
## Version 4.9.2 (2026-09-14) ## Version 4.9.2 (2026-09-14)
### Fixes ### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "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", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
+13 -2
View File
@@ -2420,8 +2420,18 @@ document.addEventListener('DOMContentLoaded', async () => {
const { renderFlowChartPanel } = require('./sidebar/flowchart-panel'); const { renderFlowChartPanel } = require('./sidebar/flowchart-panel');
// Reuse the existing Mermaid render path used by the preview pane // Reuse the existing Mermaid render path used by the preview pane
// (src/renderer.js:1106-1142). Lazily loads mermaid on first use. // (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 <div>) 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) => { const renderFlowChartMermaid = (source, targetEl) => {
targetEl.innerHTML = ''; flowchartRenderInFlight.add(targetEl);
targetEl.replaceChildren();
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'mermaid'; div.className = 'mermaid';
div.textContent = source; div.textContent = source;
@@ -2434,7 +2444,8 @@ document.addEventListener('DOMContentLoaded', async () => {
window.mermaid.initialize({ startOnLoad: false, theme, securityLevel: 'loose' }); window.mermaid.initialize({ startOnLoad: false, theme, securityLevel: 'loose' });
window.mermaid window.mermaid
.run({ nodes: [div] }) .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 // Pre-resolve the userData path on first panel render and cache it. The
// flowchart panel's getUserDataPath() must be sync (it is interpolated into // flowchart panel's getUserDataPath() must be sync (it is interpolated into
+120
View File
@@ -113,6 +113,126 @@ describe('flowchart-panel: live preview', () => {
expect(source).toMatch(/^flowchart TD/); expect(source).toMatch(/^flowchart TD/);
expect(source).toMatch(/Preview me/); 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 <pre class="flowchart-preview-source">
// 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
// <div class="mermaid"> 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 <div class="mermaid"> 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 <pre> 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 `<div class="mermaid">` 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 = `<svg data-source="${source.length}"></svg>`;
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 <pre> 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 <div class="mermaid"> elements.
expect(previewRenderEl.childNodes.length).toBeLessThanOrEqual(1);
if (previewRenderEl.firstElementChild) {
// The surviving child must be the latest render's <div class="mermaid">
// with its innerHTML replaced by an <svg> (mirrors mermaid.run's
// `element.innerHTML = svg`). Never a stale <div> 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', () => { describe('flowchart-panel: insert at cursor', () => {