The verdict has to reach the editor while she is still typing

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
This commit is contained in:
prosolis
2026-07-29 00:15:34 -07:00
parent 1fdc206576
commit c719effe1d
2 changed files with 28 additions and 4 deletions
+12 -1
View File
@@ -81,7 +81,18 @@ export default function App() {
return wordCountRef.current === 0 && (t === '' || t === 'Untitled') 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. // 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 // 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 // Mandarin native drafting English quotes Chinese constantly, and none of that
+16 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' 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' import { clearDraft, stashDraft } from '../lib/drafts'
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out' 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 // 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: // PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar:
// pending → saving → saved (fades to idle after 3s). // 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<SaveStatus>('idle') const [status, setStatus] = useState<SaveStatus>('idle')
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -25,6 +32,11 @@ export function useAutoSave(docId: string | null) {
// the loop stops here and the writing waits in localStorage instead. // the loop stops here and the writing waits in localStorage instead.
const signedOutRef = useRef(false) 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 flush = useCallback(async () => {
const id = docIdRef.current const id = docIdRef.current
const body = pendingRef.current const body = pendingRef.current
@@ -39,7 +51,8 @@ export function useAutoSave(docId: string | null) {
setStatus('saving') setStatus('saving')
try { 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 clearDraft(id) // it's on the server now; the rescue copy is redundant
setStatus('saved') setStatus('saved')
clearTimeout(fadeRef.current) clearTimeout(fadeRef.current)