{"record":{"id":"532450b6800a0fd8","repo":"block/buzz","slug":"thread-fetch-completed-but-expected-event-expect","errorCode":null,"errorMessage":"Thread fetch completed but expected event ${expectedEventId} is absent.","messagePattern":"Thread fetch completed but expected event (.+?) is absent\\.","errorType":"exception","errorClass":"ThreadExpectedEventMissingError","httpStatus":null,"severity":"error","filePath":"desktop/src/features/messages/useThreadReplies.ts","lineNumber":83,"sourceCode":"        (event) => !idsAtStart.has(event.id),\n      );\n      const result = sortMessages([...replies, ...receivedInFlight]);\n      // When the caller expects a specific reply event (e.g. opened from a\n      // notification) and the completed fetch does not contain it, the relay\n      // likely delivered an empty result before the event was replicated. Throw\n      // so React Query's retry-with-backoff re-attempts instead of caching an\n      // authoritative empty — heals automatically once the relay catches up.\n      //\n      // Once the target has been declared permanently absent (exhaustedTargets\n      // contains it), stop throwing: the successfully fetched replies are\n      // rendered and the unreachable target is quietly retired rather than\n      // painting the whole thread as a terminal load error.\n      if (\n        expectedEventId &&\n        !result.some((e) => e.id === expectedEventId) &&\n        !exhaustedTargets?.has(expectedEventId)\n      ) {\n        throw new ThreadExpectedEventMissingError(expectedEventId);\n      }\n      return result;\n    }\n    cursor = response.nextCursor;\n  }\n  throw new Error(`Thread ${rootId} exceeded the page safety limit.`);\n}\n\n/** Fetch a thread subtree into a cache independent from channel window pages. */\nexport function useThreadReplies(\n  activeChannel: Channel | null,\n  openThreadRootId: string | null,\n  expectedEventId?: string | null,\n) {\n  const channelId = activeChannel?.id ?? \"none\";\n  const rootId = openThreadRootId ?? \"none\";\n  const queryClient = useQueryClient();\n  const queryKey = threadRepliesKey(channelId, rootId);","sourceCodeStart":65,"sourceCodeEnd":101,"githubUrl":"https://github.com/block/buzz/blob/dad5a33865fc81a2e55b3b60746632f615ec1e3a/desktop/src/features/messages/useThreadReplies.ts#L65-L101","documentation":"loadThreadReplies paginates thread replies and validates the result: if the expected (originally clicked) event id is not in the fetched page set and it wasn't already covered by an exhausted target, it throws ThreadExpectedEventMissingError instead of rendering a thread that lost its anchor message. A separate plain Error guards the page safety limit.","triggerScenarios":"Opening a thread whose root/anchor event was deleted or not yet replicated, so a completed fetch returns pages lacking expectedEventId while exhaustedTargets doesn't cover it; also triggered when the thread exceeds the max-page safety limit and the loop exits without the expected event.","commonSituations":"Message was deleted on the relay between the channel view and thread open; relay outage/gap caused a partial history fetch; very long thread hitting the page safety limit; stale deep-link to a removed event.","solutions":["Refresh the channel list and reopen the thread — if the anchor was deleted, treat the thread as unavailable rather than retrying.","Check relay connectivity and re-run the REQ; a partial fetch can miss the expected event.","If the page safety limit fired, raise the limit or fetch the thread with narrower filters/time bounds.","Verify the event still exists via a direct relay query by id before rendering the thread."],"exampleFix":"// before\nconst replies = await loadThreadReplies(rootId, expectedEventId);\nrenderThread(replies);\n\n// after\ntry {\n  const replies = await loadThreadReplies(rootId, expectedEventId);\n  renderThread(replies);\n} catch (e) {\n  if (e instanceof ThreadExpectedEventMissingError) {\n    showThreadUnavailable(e.expectedEventId); // 'message was removed' state\n    return;\n  }\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"const anchor = await fetchEventById(expectedEventId); // verify it still exists before opening the thread\nif (!anchor) showThreadUnavailable();","typeGuard":"import { ThreadExpectedEventMissingError } from \"@/features/messages/useThreadReplies\";\nfunction isExpectedEventMissing(e: unknown): e is ThreadExpectedEventMissingError {\n  return e instanceof ThreadExpectedEventMissingError;\n}","tryCatchPattern":"try {\n  const replies = await loadThreadReplies(rootId, expectedEventId);\n} catch (e) {\n  if (isExpectedEventMissing(e)) {\n    renderThreadUnavailableNotice(e.expectedEventId);\n    return;\n  }\n  if (e instanceof Error && e.message.includes(\"page safety limit\")) {\n    renderPartialThreadWithLoadMore();\n    return;\n  }\n  throw e;\n}","preventionTips":["Verify the anchor event exists via direct id query when opening from deep links.","Treat exhausted-targets metadata as part of the fetch contract; always pass it through.","Keep page limits generous but bounded; offer explicit 'load more' instead of failing.","Refresh channel data on reconnect so deleted anchors are reflected before thread open."],"tags":["threads","pagination","relay","missing-event"],"backgroundTag":"expected-event-missing-in-thread","analyzedSha":"dad5a33865fc81a2e55b3b60746632f615ec1e3a","analyzedAt":"2026-09-05T18:13:50.666Z","contentChangedAt":"2026-09-05T18:13:50.666Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}