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:
larssand
2026-09-09 12:56:58 +02:00
parent be9a2de31a
commit 3373f8dddd
8 changed files with 160 additions and 6 deletions

View File

@@ -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(() => {