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/4] 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/4] 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()); }; }); From e591ccec1333416826eeb5f693a76a2ba385674b Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:13:03 +0900 Subject: [PATCH 3/4] fix: recover from interrupted session restores --- scripts/interruptedSessionRestore.test.ts | 18 ++++++++++ src/lib/MarkdownViewer.svelte | 43 ++++++++++++++++------- 2 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 scripts/interruptedSessionRestore.test.ts diff --git a/scripts/interruptedSessionRestore.test.ts b/scripts/interruptedSessionRestore.test.ts new file mode 100644 index 00000000..ce795f27 --- /dev/null +++ b/scripts/interruptedSessionRestore.test.ts @@ -0,0 +1,18 @@ +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('session restore records work in progress before restoring tabs', () => { + assert.match(viewer, /const RESTORE_IN_PROGRESS_KEY = 'markpad-window-restore-in-progress';/); + assert.match(viewer, /localStorage\.setItem\(RESTORE_IN_PROGRESS_KEY, 'true'\);/); + assert.match(viewer, /finally \{\s*localStorage\.removeItem\(RESTORE_IN_PROGRESS_KEY\);/s); +}); + +test('an interrupted restore discards saved tabs without deleting documents', () => { + assert.match(viewer, /if \(localStorage\.getItem\(RESTORE_IN_PROGRESS_KEY\)\)/); + assert.match(viewer, /await discardPersistedWindowState\(\);/); + assert.match(viewer, /async function discardPersistedWindowState\(\) \{\s*localStorage\.removeItem\(WINDOW_STATE_KEY\);\s*localStorage\.removeItem\(LEGACY_STATE_KEY\);/s); + assert.match(viewer, /await invoke\('clear_window_state'\);/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 5b7e24f2..654ed244 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -412,6 +412,7 @@ import { t } from './utils/i18n.js'; // makes old and new builds invisible to each other. const WINDOW_STATE_KEY = 'savedTabsDataV2'; const LEGACY_STATE_KEY = 'savedTabsData'; + const RESTORE_IN_PROGRESS_KEY = 'markpad-window-restore-in-progress'; // localStorage is origin-scoped, so every window shares the one snapshot // slot. Only the main window persists and restores tabs: secondary window @@ -420,6 +421,17 @@ import { t } from './utils/i18n.js'; // shared key means the last window closed overwrites everyone else. const isMainWindow = appWindow.label === 'main'; + async function discardPersistedWindowState() { + localStorage.removeItem(WINDOW_STATE_KEY); + localStorage.removeItem(LEGACY_STATE_KEY); + if (!isMainWindow) return; + try { + await invoke('clear_window_state'); + } catch (e) { + console.error('Failed to clear window state:', e); + } + } + // Persisted through Rust, not localStorage: setItem is an async message // to the WebKit storage process that dies in transit when the last // window's close ends the process (reproduced in QA as "close secondary @@ -451,16 +463,8 @@ import { t } from './utils/i18n.js'; }); if (response !== 'discard') return; } + await discardPersistedWindowState(); isForceExiting = true; - if (isMainWindow) { - try { - await invoke('clear_window_state'); - } catch (e) { - console.error('Failed to clear window state:', e); - } - localStorage.removeItem(WINDOW_STATE_KEY); - localStorage.removeItem(LEGACY_STATE_KEY); - } } appWindow.close(); } @@ -2802,12 +2806,21 @@ import { t } from './utils/i18n.js'; localStorage.getItem(WINDOW_STATE_KEY) ?? localStorage.getItem(LEGACY_STATE_KEY) ?? ((await invoke('load_window_state').catch(() => null)) as string | null); - if (savedData) { - tabManager.restoreState(savedData); + if (localStorage.getItem(RESTORE_IN_PROGRESS_KEY)) { + // A previous startup was interrupted while restoring its tabs. + // Drop only the snapshot so an unprocessable document cannot + // make every subsequent launch unusable. + console.warn('Skipping interrupted Markpad session restore'); + await discardPersistedWindowState(); + localStorage.removeItem(RESTORE_IN_PROGRESS_KEY); + } else if (savedData) { + localStorage.setItem(RESTORE_IN_PROGRESS_KEY, 'true'); + try { + tabManager.restoreState(savedData); // The snapshot carries window state only — content always // comes from disk, so restored tabs show the file's real // current bytes. A file that no longer exists drops its tab. - for (const tab of [...tabManager.tabs]) { + for (const tab of [...tabManager.tabs]) { try { const raw = (await invoke('read_file_content', { path: tab.path })) as string; if (disposed) return; @@ -2824,6 +2837,12 @@ import { t } from './utils/i18n.js'; console.warn('Restore: dropping tab for unreadable file', tab.path, e); tabManager.closeTab(tab.id); } + } + } catch (e) { + console.error('Failed to restore Markpad session:', e); + await discardPersistedWindowState(); + } finally { + localStorage.removeItem(RESTORE_IN_PROGRESS_KEY); } } } From dcfdeb9d19edc4a7f56c5b5d44c8b46e6642d1ac Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:54:19 +0800 Subject: [PATCH 4/4] test: cover shared window-state cleanup --- scripts/windowStateRestore.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/windowStateRestore.test.ts b/scripts/windowStateRestore.test.ts index a5643646..471cd9bd 100644 --- a/scripts/windowStateRestore.test.ts +++ b/scripts/windowStateRestore.test.ts @@ -90,12 +90,18 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys viewer, /localStorage\.getItem\(WINDOW_STATE_KEY\) \?\?\n?\s*localStorage\.getItem\(LEGACY_STATE_KEY\)/, ); - // explicit exit clears the file and both keys + // The shared helper clears the Rust snapshot and both localStorage keys; + // explicit exit and interrupted restore both use this same cleanup path. + const discardStart = viewer.indexOf('async function discardPersistedWindowState'); + const discardScope = viewer.slice(discardStart, viewer.indexOf('\n\t}\n\n\t// Persisted', discardStart)); + assert.match(discardScope, /clear_window_state/); + assert.match(discardScope, /removeItem\(WINDOW_STATE_KEY\)/); + assert.match(discardScope, /removeItem\(LEGACY_STATE_KEY\)/); + assert.match(viewer, /if \(localStorage\.getItem\(RESTORE_IN_PROGRESS_KEY\)\)[\s\S]*?await discardPersistedWindowState\(\)/); + // Explicit exit delegates to the same cleanup path. const exitFn = viewer.slice(viewer.indexOf('async function appExit')); const exitScope = exitFn.slice(0, exitFn.indexOf('\n\t}')); - assert.match(exitScope, /clear_window_state/); - assert.match(exitScope, /removeItem\(WINDOW_STATE_KEY\)/); - assert.match(exitScope, /removeItem\(LEGACY_STATE_KEY\)/); + assert.match(exitScope, /await discardPersistedWindowState\(\)/); }); test('with restore enabled resolved titled tabs stay open for the snapshot', () => {