From 58fa38610fc534603ebb3f0e74a1e3753d88a06e Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Mon, 17 Aug 2026 12:47:37 -0600 Subject: [PATCH] Guard against out-of-order history responses overwriting each other (#45) refreshHistory() fires twice in quick succession on a fresh load -- once on mount, again when the WS 'joined' envelope arrives shortly after (for the #37 rejoin-resync case) -- with nothing preventing a slower/stale response (e.g. the service worker's NetworkFirst cache falling back on a delayed request) from resolving last and overwriting a newer, correct one. Track the latest-initiated request and drop any response that isn't from it. Confirmed via a production DB check that there are no duplicate created_at timestamps, ruling out the timestamp-precision theory -- the actual scrambling was two competing fetches racing, not a data problem. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/ChatPane.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index 241a5f9..0c531d6 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { NetworkError } from '../api/client' import { getRoomMessages, markRoomRead } from '../api/rooms' import type { ChatSocketHandle } from '../ws/useChatSocket' @@ -33,6 +33,20 @@ export function ChatPane({ const [wsError, setWsError] = useState(null) const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false) + // refreshHistory fires more than once in quick succession on a fresh load + // -- once from the mount effect below, then again as soon as the WS's + // 'joined' envelope arrives (needed for the #37 rejoin-resync case). Nothing + // guarantees those two requests *resolve* in the order they were sent -- + // the service worker's NetworkFirst cache (sw.ts) can fall back to a stale + // cached response if one of them is slow, and a plain .then(setHistory) + // would then let whichever response lands last win even if it's the older/ + // incomplete one. This tracks the latest-initiated request and ignores any + // response that isn't from it, so a straggler can never overwrite a newer + // result -- this was the actual cause of #45's "messages out of order on + // reload" reports (confirmed no duplicate created_at timestamps in + // production, ruling out a timestamp-precision cause). + const historyRequestIdRef = useRef(0) + const refreshHistory = useCallback(() => { // live is cleared alongside history, not just on room switch: it's // superseded by this fetch fully replacing history with the current @@ -41,9 +55,14 @@ export function ChatPane({ setLive([]) setWsError(null) setHistoryUnavailableOffline(false) + const requestId = ++historyRequestIdRef.current getRoomMessages(room.id) - .then(setHistory) + .then((msgs) => { + if (historyRequestIdRef.current !== requestId) return + setHistory(msgs) + }) .catch((err) => { + if (historyRequestIdRef.current !== requestId) return if (err instanceof NetworkError) { setHistoryUnavailableOffline(true) } else {