diff --git a/web/src/App.tsx b/web/src/App.tsx index 65fe344..1917177 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -81,7 +81,18 @@ export default function App() { return wordCountRef.current === 0 && (t === '' || t === 'Untitled') }, []) - const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null) + // Only `doc_lang` is lifted out of the save response, and only when it moved. + // It is the one field the server decides on its own — the checkpoint pass reads + // the whole document and writes back whether it is English or hers — so it is + // the one field that would otherwise go stale under her while she writes. Read + // -aloud is what notices: a Portuguese paragraph read in an American voice. + // Everything else in the response is what the client just sent, and copying it + // back mid-keystroke would be a way to lose a character, not to gain one. + const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null, (saved) => + setCurrentDoc((prev) => + prev && prev.id === saved.id && prev.doc_lang !== saved.doc_lang ? { ...prev, doc_lang: saved.doc_lang } : prev, + ), + ) // The Chinese word list, for a writer going the other way through the zh pair. // Gated on the account's own setting rather than on anything in the text: a // Mandarin native drafting English quotes Chinese constantly, and none of that diff --git a/web/src/hooks/useAutoSave.ts b/web/src/hooks/useAutoSave.ts index 8d0eb3a..de95ccb 100644 --- a/web/src/hooks/useAutoSave.ts +++ b/web/src/hooks/useAutoSave.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { api, UnauthorizedError, type DocUpdate } from '../api/client' +import { api, UnauthorizedError, type Document, type DocUpdate } from '../api/client' import { clearDraft, stashDraft } from '../lib/drafts' export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out' @@ -10,7 +10,14 @@ const SAVED_FADE_MS = 3000 // useAutoSave debounces document saves. Call schedule() on every edit; it fires // PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar: // pending → saving → saved (fades to idle after 3s). -export function useAutoSave(docId: string | null) { +// +// `onSaved` receives the row the server wrote back. The save response used to be +// discarded, which was fine while every field in it was one the client had just +// sent — and stopped being fine when `doc_lang` arrived, a field only the server +// can decide. Without this the verdict reached the editor on open and never +// again, so a document that turned Portuguese while she typed went on being read +// aloud in English until the next reload. +export function useAutoSave(docId: string | null, onSaved?: (doc: Document) => void) { const [status, setStatus] = useState('idle') const debounceRef = useRef>(undefined) @@ -25,6 +32,11 @@ export function useAutoSave(docId: string | null) { // the loop stops here and the writing waits in localStorage instead. const signedOutRef = useRef(false) + // Read through a ref so a caller passing an inline arrow doesn't have to + // memoize it to keep flush stable. + const onSavedRef = useRef(onSaved) + onSavedRef.current = onSaved + const flush = useCallback(async () => { const id = docIdRef.current const body = pendingRef.current @@ -39,7 +51,8 @@ export function useAutoSave(docId: string | null) { setStatus('saving') try { - await api.updateDoc(id, body) + const saved = await api.updateDoc(id, body) + onSavedRef.current?.(saved) clearDraft(id) // it's on the server now; the rescue copy is redundant setStatus('saved') clearTimeout(fadeRef.current)