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:
2728
src-tauri/src/vault.rs
Normal file
2728
src-tauri/src/vault.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user