From 4608c977b749b325c24f703a3a86543ec3f3da0a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 22:14:02 +0100 Subject: [PATCH] fix(web): keep review text on a failed save; review round 2 - Review draft sync is a pure helper (format.ts): incoming values are ignored while a save is in flight, and a failed save restores the previous synced value, so the typed text is kept whichever of the rollback and the error callback lands first. Tests cover both orders. - A failed background refetch keeps showing the cached entry instead of the error screen (it unmounted the review box mid-outage). - History restore joins the entry-patch mutation scope. - updatePreferences: read-modify-write in a transaction, row locked on Postgres. - Invalidate auth-status after register; out-of-range rating errors speak in the user's scale. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/DetailBoxes.tsx | 56 ++++++++++++++++------- apps/web/src/components/EntryDetail.tsx | 6 ++- apps/web/src/components/HistoryDrawer.tsx | 2 + apps/web/src/format.ts | 47 ++++++++++++++++++- apps/web/src/routes/register.tsx | 1 + apps/web/test/format.test.ts | 34 +++++++++++++- packages/core/src/preferences.ts | 24 +++++++--- 7 files changed, 140 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/DetailBoxes.tsx b/apps/web/src/components/DetailBoxes.tsx index 304c111..af46f7e 100644 --- a/apps/web/src/components/DetailBoxes.tsx +++ b/apps/web/src/components/DetailBoxes.tsx @@ -6,6 +6,10 @@ import { orderEngagements, setCustomValue } from "../edits.ts"; import { actorConnectionId, describeRating, + draftFailedSave, + draftIncoming, + draftSaved, + draftStartSave, engagementNouns, formatDateRange, formatPartialDate, @@ -16,6 +20,7 @@ import { rollupLabel, statusLabel, timeAgo, + type Draft, type PartialDate, } from "../format.ts"; import { useEntryPatch, useSources } from "../hooks.ts"; @@ -357,6 +362,7 @@ export function SourcesBox({ entry, engagements }: { entry: EntryView; engagemen export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) { const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 }); const patch = useEntryPatch(entry.id); + const scale = useRatingScale(); if (!defs.data?.length) return null; const save = (def: CustomFieldDef, value: unknown) => patch.edit((cur) => { @@ -370,7 +376,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin save(d, v)} /> ))} - {patch.error &&

{friendlyError(patch.error)}

} + {patch.error &&

{friendlyError(patch.error, { ratingScale: scale })}

} ); } @@ -494,21 +500,33 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) export function ReviewBox({ entry }: { entry: EntryView }) { const patch = useEntryPatch(entry.id); const [saved, setSaved] = useState(false); - const [text, setText] = useState(entry.review ?? ""); - // The server value this text was last in sync with. When the review changes elsewhere (Restore, - // another tab) and the user has not typed since, show the new value, so a later blur can't undo it. - const synced = useRef(entry.review ?? ""); - useEffect(() => { - const incoming = entry.review ?? ""; - if (incoming === synced.current) return; - setText((cur) => (cur === synced.current ? incoming : cur)); - synced.current = incoming; - }, [entry.review]); + // Local text plus the server value it last agreed with (see `Draft` in format.ts). A Restore shows + // up here unless the user has typed since; a failed save keeps what they typed. + const [draft, setDraft] = useState(() => ({ text: entry.review ?? "", synced: entry.review ?? "", saving: false })); + const inFlight = useRef(0); + useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]); const save = () => { - const next = text.trim() === "" ? null : text; - if ((next ?? "") === (entry.review ?? "")) return; - synced.current = next ?? ""; - patch.mutate({ review: next }, { onSuccess: () => setSaved(true) }); + const next = draft.text.trim() === "" ? null : draft.text; + const stored = next ?? ""; + if (stored === draft.synced && stored === (entry.review ?? "")) return; + const was = draft.synced; + inFlight.current += 1; + setDraft((d) => ({ ...draftStartSave(d), synced: stored })); + const settle = (d: Draft) => ({ ...d, saving: inFlight.current > 0 }); + patch.mutate( + { review: next }, + { + onSuccess: () => { + inFlight.current -= 1; + setDraft((d) => settle(draftSaved(d, stored))); + setSaved(true); + }, + onError: () => { + inFlight.current -= 1; + setDraft((d) => settle(draftFailedSave(d, was))); + }, + }, + ); }; return ( Your review (Markdown)