diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index ea786738..2234975d 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -593,6 +593,8 @@ flowchart LR Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path. +Autosave then waits for a 1.5s idle window before invoking `save_note_content`. If an older save resolves after the user has already typed newer content, the older save is treated as stale and cannot clear the newer pending buffer or repaint tab state over it; the latest pending content remains scheduled for its own save. + ### Wikilink Navigation Two navigation mechanisms: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index edd5dd93..b05bcb07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,7 +233,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win - `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`) - `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window) - `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance -- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload +- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload - Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows - Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels diff --git a/docs/adr/0102-low-end-safe-autosave-idle-window.md b/docs/adr/0102-low-end-safe-autosave-idle-window.md new file mode 100644 index 00000000..5094793b --- /dev/null +++ b/docs/adr/0102-low-end-safe-autosave-idle-window.md @@ -0,0 +1,29 @@ +--- +type: ADR +id: "0102" +title: "Low-end-safe autosave idle window" +status: active +date: 2026-04-30 +supersedes: "0015" +--- + +## Context + +ADR-0015 chose a 500ms autosave debounce so normal edits would persist quickly without writing on every keystroke. GitHub issue #443 showed that this window is too aggressive on very weak Windows CPUs: if typing intervals exceed 500ms or a save takes long enough to overlap continued typing, Tolaria can start disk and derived-state work while the user is still entering text. + +## Decision + +**Tolaria autosaves after a 1.5s idle window and treats stale in-flight autosaves as obsolete when newer content arrives before they resolve.** Manual saves, note switches, raw-mode entry, and destructive actions still flush pending editor content immediately. + +## Options Considered + +- **Option A — 1.5s idle window plus stale-save protection** (chosen): reduces mid-typing saves on slow CPUs while keeping ordinary autosave behavior fast enough for reliability. It also prevents an older slow save from clearing or repainting over newer pending text. +- **Option B — Keep 500ms and only fix stale saves**: preserves the previous timing but still triggers repeated saves for slower typists and weaker machines. +- **Option C — Save only on blur or navigation**: minimizes background work but increases crash-loss risk during longer writing sessions. + +## Consequences + +- Autosave is less likely to compete with active typing on low-end Windows hardware. +- The unsaved window grows from roughly 500ms to roughly 1.5s after Tolaria receives the latest content change. +- Explicit flush paths remain immediate, so navigation, manual save, raw-mode transitions, and destructive actions still preserve pending edits before proceeding. +- Future changes to autosave timing should keep weak-CPU responsiveness and stale in-flight save behavior in the same test surface. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7eb9c07e..4c1e7b22 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -70,7 +70,7 @@ proposed → active → superseded | [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active | | [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) | | [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active | -| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active | +| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | superseded → [0102](0102-low-end-safe-autosave-idle-window.md) | | [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active | | [0017](canary-release-channel-and-local-feature-flags.md) | Canary release channel and feature flags | superseded → [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | | [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | superseded → [0064](0064-ratcheted-codescene-thresholds.md) | @@ -154,3 +154,4 @@ proposed → active → superseded | [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active | | [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active | | [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active | +| [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active | diff --git a/package.json b/package.json index 6e25d0f7..2403af93 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts", + "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts", "playwright:regression": "playwright test tests/smoke/", "playwright:integration": "playwright test --config playwright.integration.config.ts", "test:coverage": "node scripts/run-vitest-coverage.mjs", diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index a1634043..0348fd05 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import type { SetStateAction } from 'react' import { useAppSave } from './useAppSave' +import { AUTO_SAVE_DEBOUNCE_MS } from './useEditorSave' import type { VaultEntry } from '../types' import { isTauri } from '../mock-tauri' import { invoke } from '@tauri-apps/api/core' @@ -247,7 +248,7 @@ describe('useAppSave', () => { rerender({ vaultPath: '' }) await act(async () => { - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything()) @@ -339,7 +340,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) @@ -386,7 +387,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS + 2_500) }) expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) @@ -398,7 +399,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange('/vault/untitled-note-123.md', '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS + 2_500) }) expect(deps.handleSwitchTab).toHaveBeenCalledWith(newPath) @@ -412,7 +413,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange('/vault/untitled-note-123.md', '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS + 2_500) }) expect(startTransitionMock).toHaveBeenCalled() @@ -441,7 +442,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) rerender({ currentActiveTabPath: '/vault/other.md' }) @@ -458,12 +459,12 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS + 2_500) }) await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody\n\nMore text') - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') @@ -509,7 +510,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody\n\nMore text') - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') @@ -560,7 +561,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) currentTabs = [{ entry, content: '# Fresh Title\n\nBody that keeps changing while rename is pending' }] @@ -588,7 +589,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') - await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS + 2_500) }) expect(deps.onInternalVaultWrite).toHaveBeenCalledWith(oldPath) @@ -656,7 +657,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, initialContent) - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) await act(async () => { @@ -666,7 +667,7 @@ describe('useAppSave', () => { }) await act(async () => { - await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS - 200) }) const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') @@ -692,7 +693,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, initialContent) - await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS + 2_500) }) const saveCallsBeforeRename = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') @@ -704,7 +705,7 @@ describe('useAppSave', () => { await act(async () => { result.current.handleContentChange(oldPath, bodyDuringRename) - await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) const saveCallsWhileRenamePending = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') diff --git a/src/hooks/useEditorSave.test.ts b/src/hooks/useEditorSave.test.ts index 47a45b2e..6ea766f7 100644 --- a/src/hooks/useEditorSave.test.ts +++ b/src/hooks/useEditorSave.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useEditorSave } from './useEditorSave' +import { AUTO_SAVE_DEBOUNCE_MS, useEditorSave } from './useEditorSave' const mockInvokeFn = vi.fn<(cmd: string, args?: Record) => Promise>(() => Promise.resolve(null)) @@ -296,7 +296,66 @@ describe('useEditorSave', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) - it('auto-saves 500ms after last content change', async () => { + it('waits for a longer idle window so slower typing does not save mid-keystream', async () => { + const lowEndTypingIntervalMs = 900 + const { result } = renderHook(() => + useEditorSave({ updateVaultContent, setTabs, setToastMessage }) + ) + + act(() => { result.current.handleContentChange('/test/note.md', 'draft 1') }) + await act(async () => { vi.advanceTimersByTime(lowEndTypingIntervalMs) }) + expect(mockInvokeFn).not.toHaveBeenCalled() + + act(() => { result.current.handleContentChange('/test/note.md', 'draft 2') }) + await act(async () => { vi.advanceTimersByTime(lowEndTypingIntervalMs) }) + expect(mockInvokeFn).not.toHaveBeenCalled() + + await act(async () => { + vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS - lowEndTypingIntervalMs) + }) + + expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { + path: '/test/note.md', + content: 'draft 2', + }) + }) + + it('keeps newer pending content when an earlier slow auto-save resolves during typing', async () => { + let resolveFirstSave!: () => void + const firstSave = new Promise((resolve) => { resolveFirstSave = resolve }) + mockInvokeFn + .mockImplementationOnce(() => firstSave) + .mockResolvedValue(undefined) + const { result } = renderHook(() => + useEditorSave({ updateVaultContent, setTabs, setToastMessage }) + ) + + act(() => { result.current.handleContentChange('/test/note.md', 'draft 1') }) + await act(async () => { + vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) + await Promise.resolve() + }) + expect(mockInvokeFn).toHaveBeenCalledTimes(1) + + act(() => { result.current.handleContentChange('/test/note.md', 'draft 2') }) + await act(async () => { + resolveFirstSave() + await firstSave + await Promise.resolve() + }) + + expect(updateVaultContent).not.toHaveBeenCalledWith('/test/note.md', 'draft 1') + + await act(async () => { vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) }) + + expect(mockInvokeFn).toHaveBeenCalledTimes(2) + expect(mockInvokeFn).toHaveBeenLastCalledWith('save_note_content', { + path: '/test/note.md', + content: 'draft 2', + }) + }) + + it('auto-saves after the idle debounce following the last content change', async () => { const onNotePersisted = vi.fn() const { result } = renderHook(() => useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted }) @@ -309,8 +368,7 @@ describe('useEditorSave', () => { // Not saved yet expect(mockInvokeFn).not.toHaveBeenCalled() - // Advance 500ms - await act(async () => { vi.advanceTimersByTime(500) }) + await act(async () => { vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) }) expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/test/note.md', @@ -326,18 +384,16 @@ describe('useEditorSave', () => { act(() => { result.current.handleContentChange('/test/note.md', 'v1') }) - // Advance 400ms (not yet 500ms) - await act(async () => { vi.advanceTimersByTime(400) }) + const almostIdleMs = AUTO_SAVE_DEBOUNCE_MS - 100 + await act(async () => { vi.advanceTimersByTime(almostIdleMs) }) expect(mockInvokeFn).not.toHaveBeenCalled() // New edit resets timer act(() => { result.current.handleContentChange('/test/note.md', 'v2') }) - // Another 400ms (800ms total, but only 400ms from last edit) - await act(async () => { vi.advanceTimersByTime(400) }) + await act(async () => { vi.advanceTimersByTime(almostIdleMs) }) expect(mockInvokeFn).not.toHaveBeenCalled() - // 100ms more = 500ms from last edit await act(async () => { vi.advanceTimersByTime(100) }) expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { path: '/test/note.md', @@ -351,7 +407,7 @@ describe('useEditorSave', () => { ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) - await act(async () => { vi.advanceTimersByTime(500) }) + await act(async () => { vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) }) expect(setToastMessage).not.toHaveBeenCalled() }) @@ -365,7 +421,7 @@ describe('useEditorSave', () => { ) act(() => { result.current.handleContentChange(path, 'draft from auto-save') }) - await act(async () => { await vi.advanceTimersByTimeAsync(500) }) + await act(async () => { await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) }) expect(setToastMessage).toHaveBeenCalledWith( 'Save failed: The note path is invalid on this platform. Rename the note or move it to a valid folder, then try again.', @@ -395,8 +451,7 @@ describe('useEditorSave', () => { expect(mockInvokeFn).toHaveBeenCalledTimes(1) expect(setToastMessage).toHaveBeenCalledWith('Saved') - // Advancing timer should NOT cause a second save - await act(async () => { vi.advanceTimersByTime(500) }) + await act(async () => { vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) }) expect(mockInvokeFn).toHaveBeenCalledTimes(1) }) @@ -407,7 +462,7 @@ describe('useEditorSave', () => { ) act(() => { result.current.handleContentChange('/test/note.md', 'content') }) - await act(async () => { vi.advanceTimersByTime(500) }) + await act(async () => { vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) }) expect(onAfterSave).toHaveBeenCalled() }) @@ -420,7 +475,7 @@ describe('useEditorSave', () => { act(() => { result.current.handleContentChange('/test/note.md', 'content') }) unmount() - await act(async () => { vi.advanceTimersByTime(500) }) + await act(async () => { vi.advanceTimersByTime(AUTO_SAVE_DEBOUNCE_MS) }) // Should not save after unmount expect(mockInvokeFn).not.toHaveBeenCalled() }) diff --git a/src/hooks/useEditorSave.ts b/src/hooks/useEditorSave.ts index 16772108..f5f09683 100644 --- a/src/hooks/useEditorSave.ts +++ b/src/hooks/useEditorSave.ts @@ -28,11 +28,11 @@ interface EditorSaveConfig { /** * Hook that manages editor content persistence with auto-save. - * Content is auto-saved 500ms after the last edit. Cmd+S flushes immediately. + * Content is auto-saved after a short idle window. Cmd+S flushes immediately. */ const noop = () => {} -const AUTO_SAVE_DEBOUNCE_MS = 500 +export const AUTO_SAVE_DEBOUNCE_MS = 1_500 export const MISSING_ACTIVE_VAULT_SAVE_MESSAGE = 'Select or restore a vault before saving.' type Translator = ReturnType @@ -90,24 +90,31 @@ function matchesPendingPath( return resolveBufferedPath(pending.path, resolvePath) === resolveBufferedPath(pathFilter, resolvePath) } +function matchesPendingContent( + pending: PendingContent | null, + path: string, + content: string, + resolvePath?: EditorSaveConfig['resolvePath'], +): pending is PendingContent { + return matchesPendingPath(pending, path, resolvePath) && pending.content === content +} + async function persistResolvedContent({ path, content, saveNote, - onNotePersisted, resolvePath, resolvePathBeforeSave, }: { path: string content: string saveNote: (path: string, content: string) => Promise - onNotePersisted?: EditorSaveConfig['onNotePersisted'] resolvePath?: EditorSaveConfig['resolvePath'] resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] -}): Promise { +}): Promise { const targetPath = await resolvePersistPath(path, resolvePath, resolvePathBeforeSave) await saveNote(targetPath, content) - onNotePersisted?.(targetPath, content) + return targetPath } function applyTabContent( @@ -171,15 +178,18 @@ function usePendingContentFlush({ if (!matchesPendingPath(pending, pathFilter, resolvePath)) return false if (!canPersistRef.current) return false const { path, content } = pending - await persistResolvedContent({ + const targetPath = await persistResolvedContent({ path, content, saveNote, - onNotePersisted, resolvePath, resolvePathBeforeSave, }) + if (!matchesPendingContent(pendingContentRef.current, targetPath, content, resolvePath)) { + return false + } pendingContentRef.current = null + onNotePersisted?.(targetPath, content) return true }, [canPersistRef, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote]) } @@ -209,14 +219,14 @@ async function persistUnsavedFallback({ resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave'] }): Promise { if (!unsavedFallback) return false - await persistResolvedContent({ + const targetPath = await persistResolvedContent({ path: unsavedFallback.path, content: unsavedFallback.content, saveNote, - onNotePersisted, resolvePath, resolvePathBeforeSave, }) + onNotePersisted?.(targetPath, unsavedFallback.content) return true } @@ -464,9 +474,15 @@ export function useEditorSave({ const disabledSaveText = disabledSaveMessage ?? t('save.toast.missingActiveVault') const updateTabAndContent = useCallback((path: string, content: string) => { + if ( + pendingContentRef.current + && !matchesPendingContent(pendingContentRef.current, path, content, resolvePath) + ) { + return + } updateVaultContent(path, content) applyTabContent(setTabs, path, content) - }, [updateVaultContent, setTabs]) + }, [pendingContentRef, resolvePath, updateVaultContent, setTabs]) const { saveNote } = useSaveNote(updateTabAndContent) const onAfterSaveRef = useOnAfterSaveRef(onAfterSave) diff --git a/tests/smoke/autosave-low-end-typing.spec.ts b/tests/smoke/autosave-low-end-typing.spec.ts new file mode 100644 index 00000000..bbb69e3a --- /dev/null +++ b/tests/smoke/autosave-low-end-typing.spec.ts @@ -0,0 +1,101 @@ +import fs from 'fs' +import path from 'path' +import { test, expect, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVault, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { executeCommand, openCommandPalette } from './helpers' + +interface AutosaveProbeWindow { + __autosaveProbe?: Array<{ path: string; content: string }> +} + +let tempVaultDir: string + +async function openNote(page: Page, title: string) { + await page.getByTestId('note-list-container').getByText(title, { exact: true }).click() +} + +async function openRawMode(page: Page) { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 }) +} + +async function setRawEditorContent(page: Page, content: string) { + await page.evaluate((nextContent) => { + const el = document.querySelector('.cm-content') + if (!el) throw new Error('CodeMirror content element is missing') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = (el as any).cmTile?.view + if (!view) throw new Error('CodeMirror view is missing') + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: nextContent }, + }) + }, content) +} + +async function installAutosaveProbe(page: Page) { + await page.evaluate(() => { + const probeWindow = window as typeof window & AutosaveProbeWindow + const nativeFetch = window.fetch.bind(window) + const calls: Array<{ path: string; content: string }> = [] + probeWindow.__autosaveProbe = calls + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === 'string' + ? input + : input instanceof Request + ? input.url + : input.toString() + if (requestUrl.endsWith('/api/vault/save') && init?.body) { + const body = JSON.parse(String(init.body)) as { path?: unknown; content?: unknown } + calls.push({ + path: String(body.path ?? ''), + content: String(body.content ?? ''), + }) + } + return nativeFetch(input, init) + } + }) +} + +async function readAutosaveProbe(page: Page) { + return page.evaluate(() => { + const probeWindow = window as typeof window & AutosaveProbeWindow + return probeWindow.__autosaveProbe ?? [] + }) +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) + await installAutosaveProbe(page) +}) + +test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('@smoke autosave waits for idle typing and persists the latest draft only', async ({ page }) => { + const notePath = path.join(tempVaultDir, 'note', 'note-b.md') + const firstDraft = `# Note B\n\nLow-end autosave first draft ${Date.now()}` + const latestDraft = `${firstDraft}\n\nLatest draft after continued typing` + + await openNote(page, 'Note B') + await openRawMode(page) + await setRawEditorContent(page, firstDraft) + await page.waitForTimeout(900) + await setRawEditorContent(page, latestDraft) + await page.waitForTimeout(450) + + expect(await readAutosaveProbe(page)).toEqual([]) + + await expect.poll(() => readAutosaveProbe(page), { timeout: 5_000 }).toEqual([ + expect.objectContaining({ path: notePath, content: latestDraft }), + ]) + expect(fs.readFileSync(notePath, 'utf8')).toBe(latestDraft) +})