fix(sidebar): clean up listeners + timers + store subscription on panel destroy

destroy() previously only called canvas.destroy(), leaking:
- the container keydown listener
- the insertBtn click listener
- the debouncedPreview setTimeout handle (could write to a detached <pre>)
- the debouncedPersist setTimeout handle (could writeFile after unmount)
- the store.subscribe listener (store kept a stale closure)

Also compute the persistence path once on mount instead of invoking
getUserDataPath() three times per panel lifecycle.

Inline debouncedPreview / debouncedPersist so their timer handles are
reachable from destroy(). Capture store.subscribe's unsubscribe.
destroy() order: clear timers, unsubscribe store, remove listeners,
then canvas.destroy().

Amit Haridas
This commit is contained in:
2026-09-14 21:49:49 +05:30
parent 59ee9c609e
commit a6dc54cedc
+51 -29
View File
@@ -28,21 +28,6 @@ const PREVIEW_DEBOUNCE_MS = 250;
const PERSIST_DEBOUNCE_MS = 500; const PERSIST_DEBOUNCE_MS = 500;
const PERSISTENCE_FILENAME = 'flowchart-session.json'; const PERSISTENCE_FILENAME = 'flowchart-session.json';
function persistencePath(getUserDataPath) {
return `${getUserDataPath()}/${PERSISTENCE_FILENAME}`;
}
function debounce(fn, ms) {
let handle = null;
return (...args) => {
if (handle) clearTimeout(handle);
handle = setTimeout(() => {
handle = null;
fn(...args);
}, ms);
};
}
function renderFlowChartPanel(container, deps) { function renderFlowChartPanel(container, deps) {
const { getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid = () => {} } = deps; const { getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid = () => {} } = deps;
if (typeof getUserDataPath !== 'function') { if (typeof getUserDataPath !== 'function') {
@@ -55,6 +40,10 @@ function renderFlowChartPanel(container, deps) {
throw new Error('flowchart-panel: insertAtCursor is required'); throw new Error('flowchart-panel: insertAtCursor is required');
} }
// Compute the persistence path ONCE on mount. Re-resolving per call would
// hit the filesystem / IPC bridge unnecessarily on every debounced write.
const persistenceFile = `${getUserDataPath()}/${PERSISTENCE_FILENAME}`;
container.innerHTML = ` container.innerHTML = `
<div class="flowchart-panel" tabindex="0"> <div class="flowchart-panel" tabindex="0">
<div class="flowchart-toolbar"> <div class="flowchart-toolbar">
@@ -83,14 +72,14 @@ function renderFlowChartPanel(container, deps) {
let selectedEdgeId = null; let selectedEdgeId = null;
const store = createStore({ const store = createStore({
persistencePath: persistencePath(getUserDataPath), persistencePath: persistenceFile,
readFile, readFile,
writeFile, writeFile,
now: () => Date.now(), now: () => Date.now(),
}); });
// Hydrate from disk (defensively). // Hydrate from disk (defensively).
readFile(persistencePath(getUserDataPath)) readFile(persistenceFile)
.then((json) => { .then((json) => {
if (json) store.deserialize(json); if (json) store.deserialize(json);
}) })
@@ -124,7 +113,10 @@ function renderFlowChartPanel(container, deps) {
}, },
}); });
const debouncedPreview = debounce(() => { // Debounced live preview. Track the timer handle so destroy() can cancel
// any in-flight update that would otherwise write to a detached <pre>.
let previewTimer = null;
function runPreview() {
const source = toMermaid(store.getGraph()); const source = toMermaid(store.getGraph());
previewSourceEl.textContent = source; previewSourceEl.textContent = source;
try { try {
@@ -132,26 +124,41 @@ function renderFlowChartPanel(container, deps) {
} catch (err) { } catch (err) {
previewRenderEl.textContent = `Preview error: ${err && err.message ? err.message : 'unknown'}`; previewRenderEl.textContent = `Preview error: ${err && err.message ? err.message : 'unknown'}`;
} }
}, PREVIEW_DEBOUNCE_MS); }
function debouncedPreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
previewTimer = null;
runPreview();
}, PREVIEW_DEBOUNCE_MS);
}
const debouncedPersist = debounce(() => { // Debounced persistence. Track the timer handle so destroy() can cancel a
writeFile(persistencePath(getUserDataPath), store.serialize()).catch((err) => { // queued writeFile that would otherwise fire after unmount.
if (statusEl) statusEl.textContent = `Save failed: ${err.message || err}`; let persistTimer = null;
}); function debouncedPersist() {
}, PERSIST_DEBOUNCE_MS); if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
writeFile(persistenceFile, store.serialize()).catch((err) => {
if (statusEl) statusEl.textContent = `Save failed: ${err.message || err}`;
});
}, PERSIST_DEBOUNCE_MS);
}
store.subscribe(() => { const unsubscribeStore = store.subscribe(() => {
debouncedPreview(); debouncedPreview();
debouncedPersist(); debouncedPersist();
}); });
insertBtn.addEventListener('click', () => { function onInsertClick() {
const source = toMermaid(store.getGraph()); const source = toMermaid(store.getGraph());
insertAtCursor('```mermaid\n' + source + '\n```'); insertAtCursor('```mermaid\n' + source + '\n```');
}); }
insertBtn.addEventListener('click', onInsertClick);
// Keyboard shortcuts — panel-scoped. // Keyboard shortcuts — panel-scoped.
container.addEventListener('keydown', (ev) => { function onContainerKeyDown(ev) {
if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') { if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') {
ev.preventDefault(); ev.preventDefault();
if (ev.shiftKey) store.redo(); if (ev.shiftKey) store.redo();
@@ -169,7 +176,8 @@ function renderFlowChartPanel(container, deps) {
selectedEdgeId = null; selectedEdgeId = null;
} }
} }
}); }
container.addEventListener('keydown', onContainerKeyDown);
return { return {
getStore: () => store, getStore: () => store,
@@ -181,6 +189,20 @@ function renderFlowChartPanel(container, deps) {
selectedEdgeId = id; selectedEdgeId = id;
}, },
destroy: () => { destroy: () => {
// Clear timers BEFORE canvas.destroy(): canvas teardown may trigger a
// last pointer-move that schedules another preview/persist; we want
// those timers cancelled before canvas.destroy() runs.
if (previewTimer) {
clearTimeout(previewTimer);
previewTimer = null;
}
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
unsubscribeStore();
container.removeEventListener('keydown', onContainerKeyDown);
insertBtn.removeEventListener('click', onInsertClick);
canvas.destroy(); canvas.destroy();
}, },
}; };