From c719effe1d3746148d2acfd24ec7c0e2e7eee4b0 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:15:34 -0700 Subject: [PATCH] The verdict has to reach the editor while she is still typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught in the browser: a Portuguese document was read aloud in an American voice until the page was reloaded. The code picking the voice was right — the verdict never arrived. useAutoSave discarded the save response, 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 decides. The hook now hands the saved row back, and App lifts exactly one field out of it, only when it changed. Copying the rest back mid-keystroke is a way to lose a character, not to gain one. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7 --- web/src/App.tsx | 13 ++++++++++++- web/src/hooks/useAutoSave.ts | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) 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)