fix: reduce autosave pressure while typing

This commit is contained in:
lucaronin
2026-04-30 19:36:10 +02:00
parent aa4d4fd12e
commit 50c304fce5
9 changed files with 249 additions and 44 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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",

View File

@@ -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')

View File

@@ -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<string, unknown>) => Promise<null>>(() => 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<void>((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()
})

View File

@@ -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<typeof createTranslator>
@@ -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<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
}): Promise<void> {
}): Promise<string> {
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<boolean> {
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)

View File

@@ -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)
})