fix: prevent editor from reverting content after Cmd+S save

The tab-swap useEffect in Editor.tsx watched [activeTabPath, tabs, editor].
When save updated tabs via setTabs, the effect re-ran and re-applied stale
cached blocks from the initial tab load, visually reverting the editor.
Subsequent edits would then save old content, effectively losing changes.

Fix: skip the block swap when activeTabPath hasn't changed (only tab content
was updated). Also refresh the cache with current editor blocks so a later
tab switch doesn't revert to stale content.

Added regression tests for the save → update round-trip (JS + Rust).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 21:56:48 +01:00
parent 0794d8f8ac
commit e51bafd4b7
3 changed files with 2789 additions and 51 deletions

2728
src-tauri/src/vault.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -266,13 +266,25 @@ export const Editor = memo(function Editor({
useEffect(() => {
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
// Save current editor state for the tab we're leaving
if (prevPath && prevPath !== activeTabPath && editorMountedRef.current) {
if (prevPath && pathChanged && editorMountedRef.current) {
cache.set(prevPath, editor.document)
}
prevActivePathRef.current = activeTabPath
// When tab content updates but the active tab stays the same (e.g. after
// Cmd+S save), refresh the cache with the current editor blocks so a later
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
// the editor already shows the user's edits.
if (!pathChanged) {
if (activeTabPath && editorMountedRef.current) {
cache.set(activeTabPath, editor.document)
}
return
}
if (!activeTabPath) return
const tab = tabs.find(t => t.entry.path === activeTabPath)

View File

@@ -156,65 +156,63 @@ describe('useEditorSave', () => {
// (We'll check via the next handleSave call)
})
it('savePending flushes pending content to disk silently', async () => {
it('save updates tab content with edited body, not original (regression)', async () => {
const { result } = renderSaveHook()
// No pending content — should be a no-op
await act(async () => {
await result.current.savePending()
})
expect(mockInvokeFn).not.toHaveBeenCalled()
// Simulate: user opens note, edits body, presses Cmd+S
const original = '---\ntitle: My Note\n---\n\n# My Note\n\nOriginal body'
const edited = '---\ntitle: My Note\n---\n\n# My Note\n\nEdited body with changes'
// Buffer content
act(() => {
result.current.handleContentChange('/test/note.md', 'pending content')
result.current.handleContentChange('/vault/note.md', edited)
})
// savePending should save without toasting
await act(async () => {
await result.current.savePending()
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note.md',
content: 'pending content',
})
expect(setToastMessage).not.toHaveBeenCalled()
// After savePending, pending is cleared
await act(async () => {
await result.current.savePending()
})
// No additional save call
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
})
it('onAfterSave: called after handleSave but NOT after savePending', async () => {
const onAfterSave = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
)
// savePending does not trigger onAfterSave (callers manage their own refresh)
act(() => { result.current.handleContentChange('/test/note.md', 'v1') })
await act(async () => { await result.current.savePending() })
expect(onAfterSave).not.toHaveBeenCalled()
// handleSave DOES trigger onAfterSave
act(() => { result.current.handleContentChange('/test/note.md', 'v2') })
await act(async () => { await result.current.handleSave() })
expect(onAfterSave).toHaveBeenCalledTimes(1)
})
it('onAfterSave is NOT called when there is nothing to save', async () => {
const onAfterSave = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
)
await act(async () => {
await result.current.handleSave()
})
expect(onAfterSave).not.toHaveBeenCalled()
// The save must persist the EDITED content, not the original
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/vault/note.md',
content: edited,
})
// Tab content must be updated with the saved (edited) content
expect(setTabs).toHaveBeenCalled()
const tabUpdater = setTabs.mock.calls[0][0]
const fakeTabs = [{ entry: { path: '/vault/note.md' }, content: original }]
const updatedTabs = tabUpdater(fakeTabs)
expect(updatedTabs[0].content).toBe(edited)
// Vault in-memory state must also reflect the edit
expect(updateVaultContent).toHaveBeenCalledWith('/vault/note.md', edited)
})
it('successive edits and saves persist each version correctly', async () => {
const { result } = renderSaveHook()
// First edit + save
act(() => {
result.current.handleContentChange('/vault/note.md', 'version 1')
})
await act(async () => {
await result.current.handleSave()
})
expect(mockInvokeFn).toHaveBeenLastCalledWith('save_note_content', {
path: '/vault/note.md',
content: 'version 1',
})
// Second edit + save — must NOT revert to version 1
act(() => {
result.current.handleContentChange('/vault/note.md', 'version 2')
})
await act(async () => {
await result.current.handleSave()
})
expect(mockInvokeFn).toHaveBeenLastCalledWith('save_note_content', {
path: '/vault/note.md',
content: 'version 2',
})
})
})