Gate editor autosave store subscriber (#1826)

* Gate editor autosave store subscriber

* fix: keep autosave controller lint-clean
This commit is contained in:
Neil
2026-05-14 01:34:39 -07:00
committed by GitHub
parent 2762fdb38c
commit 5d6992d7f0
4 changed files with 111 additions and 18 deletions
@@ -1,12 +1,13 @@
import { useEffect } from 'react'
import { attachAppEditorAutosaveController } from './editor-autosave-controller'
import { useAppStore } from '@/store'
import { attachEditorAutosaveController } from './editor-autosave-controller'
export default function EditorAutosaveController(): null {
useEffect(() => {
// Why: autosave and quit coordination need to survive editor tab switches,
// but keeping the full EditorPanel mounted while hidden widened the restart
// surface too far. Keep only this narrow controller alive between mounts.
return attachAppEditorAutosaveController()
return attachEditorAutosaveController(useAppStore)
}, [])
return null
@@ -193,6 +193,57 @@ describe('attachEditorAutosaveController', () => {
}
})
it('skips the open-file scan for unrelated store mutations', () => {
const writeFile = vi.fn().mockResolvedValue(undefined)
const eventTarget = new EventTarget()
vi.stubGlobal('window', {
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
api: {
fs: {
writeFile
}
}
} satisfies WindowStub)
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
const openFiles = store.getState().openFiles
const originalMap = openFiles.map.bind(openFiles)
let mapCalls = 0
Object.defineProperty(openFiles, 'map', {
configurable: true,
value: (...args: Parameters<typeof openFiles.map>) => {
mapCalls += 1
return originalMap(...args)
}
})
const cleanup = attachEditorAutosaveController(store)
try {
expect(mapCalls).toBe(1)
mapCalls = 0
store.setState({ activeWorktreeId: 'wt-2' } as Partial<AppState>)
expect(mapCalls).toBe(0)
store.getState().setEditorDraft('/repo/file.ts', 'edited')
expect(mapCalls).toBe(1)
} finally {
cleanup()
}
})
it('flushes mounted rich-editor changes before quiescing or direct saves', async () => {
const writeFile = vi.fn().mockResolvedValue(undefined)
const eventTarget = new EventTarget()
@@ -1,5 +1,4 @@
import type { StoreApi } from 'zustand'
import { useAppStore } from '@/store'
import type { AppState } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionId } from '@/lib/connection-context'
@@ -19,6 +18,11 @@ import {
} from './editor-autosave'
import { flushPendingEditorChange } from './editor-pending-flush'
import { clearSelfWrite, recordSelfWrite } from './editor-self-write-registry'
import {
autosaveSubscriberInputsEqual,
getAutosaveSubscriberInputs,
getDuplicateDirtySavePaths
} from './editor-autosave-state-projections'
import {
ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT,
type EditorSaveDirtyFilesDetail
@@ -26,16 +30,6 @@ import {
type AppStoreApi = Pick<StoreApi<AppState>, 'getState' | 'subscribe'>
function getDuplicateDirtySavePaths(files: OpenFile[]): string[] {
const counts = new Map<string, number>()
for (const file of files) {
counts.set(file.filePath, (counts.get(file.filePath) ?? 0) + 1)
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([filePath]) => filePath)
}
export function attachEditorAutosaveController(store: AppStoreApi): () => void {
const autoSaveTimers = new Map<string, number>()
const autoSaveScheduledContent = new Map<string, string>()
@@ -319,7 +313,18 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
state.clearEditorDrafts(matchingFiles.map((file) => file.id))
}
const unsubscribe = store.subscribe(syncAutoSave)
// Why: the root store subscriber fires for every terminal title/focus tick.
// Autosave only reads these four inputs, so skip the open-files scan when
// unrelated store slices change.
let previousAutosaveInputs = getAutosaveSubscriberInputs(store.getState())
const unsubscribe = store.subscribe(() => {
const nextAutosaveInputs = getAutosaveSubscriberInputs(store.getState())
if (autosaveSubscriberInputsEqual(previousAutosaveInputs, nextAutosaveInputs)) {
return
}
previousAutosaveInputs = nextAutosaveInputs
syncAutoSave()
})
syncAutoSave()
window.addEventListener(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, handleSaveDirtyFiles as EventListener)
@@ -356,7 +361,3 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
saveGeneration.clear()
}
}
export function attachAppEditorAutosaveController(): () => void {
return attachEditorAutosaveController(useAppStore)
}
@@ -0,0 +1,40 @@
import type { AppState } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
export type AutosaveSubscriberInputs = {
openFiles: AppState['openFiles']
editorDrafts: AppState['editorDrafts']
editorAutoSave: boolean | undefined
editorAutoSaveDelayMs: number | undefined
}
export function getAutosaveSubscriberInputs(state: AppState): AutosaveSubscriberInputs {
return {
openFiles: state.openFiles,
editorDrafts: state.editorDrafts,
editorAutoSave: state.settings?.editorAutoSave,
editorAutoSaveDelayMs: state.settings?.editorAutoSaveDelayMs
}
}
export function autosaveSubscriberInputsEqual(
a: AutosaveSubscriberInputs,
b: AutosaveSubscriberInputs
): boolean {
return (
a.openFiles === b.openFiles &&
a.editorDrafts === b.editorDrafts &&
a.editorAutoSave === b.editorAutoSave &&
a.editorAutoSaveDelayMs === b.editorAutoSaveDelayMs
)
}
export function getDuplicateDirtySavePaths(files: OpenFile[]): string[] {
const counts = new Map<string, number>()
for (const file of files) {
counts.set(file.filePath, (counts.get(file.filePath) ?? 0) + 1)
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([filePath]) => filePath)
}