Fix EOSE tracking to prevent state regression after live events

The bug: When events arrived after EOSE was received (live streaming),
the subscription state was incorrectly reset from "eose" back to
"receiving", causing:
- False "LOADING" overall state
- Relay UI showing "RECEIVING" spinner when EOSE was already received
- Incorrect eoseCount in state machine

The fix: Add a new "live" subscription state that represents
"EOSE received AND actively receiving live events". State transitions:
- waiting → receiving (on first historical event)
- receiving → eose (on EOSE signal)
- eose → live (on live event after EOSE)

Changes:
- Add "live" to RelaySubscriptionState type with proper documentation
- Add liveCount to ReqOverallState for accurate counting
- Update event handler to set "live" state when eoseAt exists
- Update EOSE completion check to include "live" state
- Update getRelayStateBadge: "live" shows green, "receiving" shows yellow
- Update ReqViewer: show pulsing radio icon for live relays
- Update tests for new state and color changes

https://claude.ai/code/session_01DVTWqKNY4UHVSDDxckjkAh
This commit is contained in:
Claude
2026-02-02 13:06:32 +00:00
parent 53d156ba04
commit 46979e11e9
5 changed files with 62 additions and 13 deletions

View File

@@ -1234,12 +1234,17 @@ export default function ReqViewer({
</div>
)}
{/* EOSE status */}
{/* Subscription status icon */}
{reqState && (
<>
{reqState.subscriptionState === "eose" ? (
{reqState.subscriptionState === "live" ? (
// Live: EOSE received + actively streaming
<Radio className="size-3 text-green-500 animate-pulse" />
) : reqState.subscriptionState === "eose" ? (
// EOSE received, idle
<Check className="size-3 text-green-600/70" />
) : (
// Receiving historical or waiting
(reqState.subscriptionState === "receiving" ||
reqState.subscriptionState ===
"waiting") && (

View File

@@ -216,10 +216,12 @@ export function useReqTimelineEnhanced(
eoseAt: Date.now(),
});
// Check if ALL relays have reached EOSE
// Check if ALL relays have reached EOSE (or beyond)
// "live" means EOSE was received and we're getting live events
const allEose = Array.from(next.values()).every(
(s) =>
s.subscriptionState === "eose" ||
s.subscriptionState === "live" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
@@ -268,10 +270,15 @@ export function useReqTimelineEnhanced(
lastEventAt: now,
});
} else {
// Update existing relay state
// Determine correct subscription state:
// - If EOSE already received (eoseAt exists), this is a LIVE event
// - Otherwise, we're still receiving historical events
const hasEose = state.eoseAt !== undefined;
const newSubscriptionState = hasEose ? "live" : "receiving";
next.set(url, {
...state,
subscriptionState: "receiving",
subscriptionState: newSubscriptionState,
eventCount: state.eventCount + 1,
firstEventAt: state.firstEventAt ?? now,
lastEventAt: now,

View File

@@ -538,6 +538,7 @@ describe("getStatusText", () => {
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
liveCount: 0,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
@@ -568,6 +569,7 @@ describe("getStatusTooltip", () => {
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
liveCount: 0,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
@@ -625,7 +627,18 @@ describe("shouldAnimate", () => {
});
describe("getRelayStateBadge", () => {
it("should return receiving badge", () => {
it("should return live badge (EOSE received + streaming)", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "live",
eventCount: 15,
});
expect(badge?.text).toBe("LIVE");
expect(badge?.color).toBe("text-success");
});
it("should return receiving badge (before EOSE)", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
@@ -633,7 +646,8 @@ describe("getRelayStateBadge", () => {
eventCount: 5,
});
expect(badge?.text).toBe("RECEIVING");
expect(badge?.color).toBe("text-success");
// Warning color because it's still loading (pre-EOSE)
expect(badge?.color).toBe("text-warning");
});
it("should return eose badge", () => {

View File

@@ -35,6 +35,7 @@ export function deriveOverallState(
(s) => s.subscriptionState === "receiving",
).length;
const eoseCount = states.filter((s) => s.subscriptionState === "eose").length;
const liveCount = states.filter((s) => s.subscriptionState === "live").length;
const errorCount = states.filter((s) => s.connectionState === "error").length;
const disconnectedCount = states.filter(
(s) => s.connectionState === "disconnected",
@@ -55,10 +56,12 @@ export function deriveOverallState(
const allEoseAt = overallEoseReceived ? Date.now() : undefined;
// Check if all relays are in terminal states (won't make further progress)
// Check if all relays are in terminal states (won't make further progress on initial load)
// "live" means EOSE received + streaming, so it's also a terminal state for initial load
const allRelaysTerminal = states.every(
(s) =>
s.subscriptionState === "eose" ||
s.subscriptionState === "live" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
@@ -143,6 +146,7 @@ export function deriveOverallState(
connectedCount,
receivingCount,
eoseCount,
liveCount,
errorCount,
disconnectedCount,
hasReceivedEvents,
@@ -242,11 +246,17 @@ export function getRelayStateBadge(
): { text: string; color: string } | null {
const { subscriptionState, connectionState } = relay;
// Prioritize subscription state
// Prioritize subscription state (order matters for display priority)
if (subscriptionState === "live") {
// EOSE received, actively receiving live events
return { text: "LIVE", color: "text-success" };
}
if (subscriptionState === "receiving") {
return { text: "RECEIVING", color: "text-success" };
// Receiving historical events (before EOSE)
return { text: "RECEIVING", color: "text-warning" };
}
if (subscriptionState === "eose") {
// EOSE received, idle (no live events yet)
return { text: "EOSE", color: "text-info" };
}
if (subscriptionState === "error") {

View File

@@ -18,11 +18,23 @@ export type RelayConnectionState =
/**
* Subscription state specific to this REQ
*
* State machine:
* waiting → receiving → eose → live
* ↘ ↗
* error
*
* - waiting: Connected, subscription sent, no events yet
* - receiving: Getting historical events (before EOSE)
* - eose: EOSE received, no live events yet
* - live: EOSE received AND receiving live events (streaming mode)
* - error: Subscription error occurred
*/
export type RelaySubscriptionState =
| "waiting" // Connected but no events yet
| "receiving" // Events being received
| "eose" // EOSE received (real or timeout)
| "receiving" // Getting historical events (before EOSE)
| "eose" // EOSE received, idle (no live events yet)
| "live" // EOSE received AND receiving live events
| "error"; // Subscription error
/**
@@ -75,7 +87,8 @@ export interface ReqOverallState {
totalRelays: number;
connectedCount: number;
receivingCount: number;
eoseCount: number;
eoseCount: number; // Relays in "eose" state (EOSE received, idle)
liveCount: number; // Relays in "live" state (EOSE received + streaming)
errorCount: number;
disconnectedCount: number;