Protect live race results and shrink cross-browser staleness
Investigated whether two admin browsers open at once can overwrite each other's changes, per an explicit request to report the risk before making bigger changes. Found that POST /api/state always replaces the entire stored document with a client's in-memory copy, with no version check and no per-field merge - and a browser only ever pushes, never re-fetching server state after page load except on the Overlay view. An idle browser's next save, even an unrelated edit, could silently revert another browser's more recent changes, worst case a live race's in-progress results. Two scoped fixes instead of the bigger optimistic-locking rework (that needs real UX decisions about conflicting saves, deferred separately): 1. server.js now remembers which session results were just written by a real live decoder passing (/api/passings) and protects that key for 15 seconds against being reverted by a stale full-state save, while still applying the rest of that save normally. 2. Every non-Overlay view now quietly refreshes its data in the background every 15 seconds (skipped while a local edit is pending), without ever calling renderView(), so it can never discard an open, unsaved edit form - shrinking how stale a browser's next save can be from hours to seconds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -254,6 +254,22 @@ export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTime
|
||||
}, 800));
|
||||
}
|
||||
|
||||
// A browser left open on any other view never re-fetches state on its own, so the copy it
|
||||
// would push on its next save only gets staler over time - the longer it sits, the more of
|
||||
// another browser's meanwhile-made edits its next save can silently discard (see
|
||||
// syncStateToBackendHelper/the whole-document overwrite it does). This keeps that copy fresh
|
||||
// in the background without ever touching the DOM, so it can never interrupt an open, unsaved
|
||||
// edit form the way calling renderView() here could. Overlay already refreshes and renders
|
||||
// itself on its own, faster cycle above, so it's left alone.
|
||||
export function startBackgroundHydrationHelper({ getBackgroundHydrationTimer, setBackgroundHydrationTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, intervalMs = 15000 }) {
|
||||
clearInterval(getBackgroundHydrationTimer());
|
||||
setBackgroundHydrationTimer(setInterval(async () => {
|
||||
if (getCurrentView() === "overlay") return;
|
||||
if (getLocalStateDirty()) return;
|
||||
await hydrateFromBackend();
|
||||
}, intervalMs));
|
||||
}
|
||||
|
||||
export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlayRotationTimer, getOverlayRotationIndex, setOverlayRotationIndex, getCurrentView, getOverlayViewMode, renderView }) {
|
||||
clearInterval(getOverlayRotationTimer());
|
||||
setOverlayRotationTimer(setInterval(() => {
|
||||
|
||||
Reference in New Issue
Block a user