From 837b76ade27cdbda5c8f9249b73fd3e265d80288 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:05:15 +0900 Subject: [PATCH 1/2] fix: ignore stale preview renders --- scripts/previewRenderRevision.test.ts | 17 ++++++++++++++++ src/lib/MarkdownViewer.svelte | 28 +++++++++++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 scripts/previewRenderRevision.test.ts diff --git a/scripts/previewRenderRevision.test.ts b/scripts/previewRenderRevision.test.ts new file mode 100644 index 00000000..64a39105 --- /dev/null +++ b/scripts/previewRenderRevision.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +test('preview rendering uses a revision and cleans up its debounce timer', () => { + assert.match(viewer, /let previewRenderRevision = 0;/); + assert.match(viewer, /const renderRevision = \+\+previewRenderRevision;/); + assert.match(viewer, /return \(\) => clearTimeout\(timer\);/); +}); + +test('a completed preview render verifies its tab, content, and revision', () => { + assert.match(viewer, /previewRenderRevision !== renderRevision/); + assert.match(viewer, /tabManager\.activeTabId !== tabId/); + assert.match(viewer, /currentTab\?\.rawContent !== rawContent/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index f2a0cd52..0509f11b 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -2353,17 +2353,33 @@ import { t } from './utils/i18n.js'; } } - let debounceTimer: number; + let previewRenderRevision = 0; $effect(() => { const tab = tabManager.activeTab; + const renderRevision = ++previewRenderRevision; if (tab && (tab.isSplit || (isEditing && settings.showToc)) && tab.rawContent !== undefined) { - if ((tab as any)._lastRenderedRawContent === tab.rawContent) return; - - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - renderTabPreviewFromRaw(tab).catch(console.error); + const tabId = tab.id; + const rawContent = tab.rawContent; + if ((tab as any)._lastRenderedRawContent === rawContent) return; + + const timer = setTimeout(() => { + renderMarkdownPreview(rawContent, tab.path) + .then((processed) => { + const currentTab = tabManager.activeTab; + if ( + previewRenderRevision !== renderRevision || + tabManager.activeTabId !== tabId || + currentTab?.rawContent !== rawContent + ) return; + tabManager.updateTabContent(tabId, processed); + (currentTab as any)._lastRenderedRawContent = rawContent; + tick().then(renderRichContent); + }) + .catch(console.error); }, 16); + + return () => clearTimeout(timer); } }); From 49071652a0b1b50286f8a48e5027f78fa422e673 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:10:53 +0900 Subject: [PATCH 2/2] fix: guard viewer initialization after disposal --- scripts/viewerDisposal.test.ts | 16 ++++++++++++++++ src/lib/MarkdownViewer.svelte | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 scripts/viewerDisposal.test.ts diff --git a/scripts/viewerDisposal.test.ts b/scripts/viewerDisposal.test.ts new file mode 100644 index 00000000..0a658400 --- /dev/null +++ b/scripts/viewerDisposal.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +test('onMount initialization tracks and checks disposal around async work', () => { + assert.match(viewer, /let disposed = false;/); + assert.match(viewer, /if \(disposed\) return;/); + assert.match(viewer, /if \(!disposed && args\?\.length > 0\)/); + assert.match(viewer, /disposed = true;/); +}); + +test('listeners registered after disposal are released', () => { + assert.match(viewer, /if \(disposed\) \{\s*unlisteners\.forEach\(\(unlisten\) => unlisten\(\)\);/s); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 0509f11b..5b7e24f2 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -2755,9 +2755,11 @@ import { t } from './utils/i18n.js'; onMount(() => { loadRecentFiles(); + let disposed = false; // @ts-ignore Promise.all([import('highlight.js'), import('highlightjs-svelte'), import('katex'), import('mermaid')]).then(async ([hljsModule, svelteModule, katexMainModule, mermaidModule]) => { + if (disposed) return; hljs = hljsModule.default; try { svelteModule.default(hljs); @@ -2773,6 +2775,7 @@ import { t } from './utils/i18n.js'; import('katex/dist/contrib/mhchem.js'), import('katex/dist/contrib/copy-tex.js') ]); + if (disposed) return; renderMathInElement = autoRenderModule.default; mermaid = mermaidModule.default; @@ -2785,6 +2788,7 @@ import { t } from './utils/i18n.js'; const init = async () => { const appWindow = getCurrentWindow(); const appMode = (await invoke('get_app_mode')) as any; + if (disposed) return; if (isMainWindow && settings.restoreStateOnReopen) { // localStorage first, Rust file as fallback. Startup always @@ -2806,14 +2810,17 @@ import { t } from './utils/i18n.js'; for (const tab of [...tabManager.tabs]) { try { const raw = (await invoke('read_file_content', { path: tab.path })) as string; + if (disposed) return; tab.rawContent = raw; tab.originalContent = raw; const processed = await renderMarkdownPreview(raw, tab.path); + if (disposed) return; tabManager.updateTabContent(tab.id, processed); if (tabManager.activeTabId === tab.id) { tick().then(renderRichContent); } } catch (e) { + if (disposed) return; console.warn('Restore: dropping tab for unreadable file', tab.path, e); tabManager.closeTab(tab.id); } @@ -2870,7 +2877,9 @@ import { t } from './utils/i18n.js'; const fileParam = urlParams.get('file'); if (fileParam) { const decodedPath = decodeURIComponent(fileParam); + if (disposed) return; await loadMarkdown(decodedPath); + if (disposed) return; } unlisteners.push( @@ -3150,6 +3159,11 @@ import { t } from './utils/i18n.js'; }), ); + if (disposed) { + unlisteners.forEach((unlisten) => unlisten()); + return; + } + // Startup-file delivery (argv / macOS Opened-before-ready stash) is // a boot-time channel that belongs to the FIRST window only. It is // process-global state: letting every window consume it meant each @@ -3157,7 +3171,7 @@ import { t } from './utils/i18n.js'; if (isMainWindow) { try { const args: string[] = await invoke('send_markdown_path'); - if (args?.length > 0) { + if (!disposed && args?.length > 0) { await loadMarkdown(args[0]); } } catch (error) { @@ -3165,12 +3179,13 @@ import { t } from './utils/i18n.js'; } } - mode = appMode; + if (!disposed) mode = appMode; }; init(); return () => { + disposed = true; unlisteners.forEach((u) => u()); }; });