From 008b2afad8bdf13f453c42eecc3387223a4111f2 Mon Sep 17 00:00:00 2001
From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com>
Date: Tue, 20 Oct 2026 02:32:11 -0400
Subject: [PATCH] Show an error with retry, not an empty list, when packets,
channels, messages or adverts fail to load (#178)
---
src/features/channels/ChannelList.tsx | 2 +-
src/features/channels/MessagePanel.tsx | 2 +-
src/features/observers/ObserverAdverts.tsx | 9 +++++++--
src/features/packets/PacketList.tsx | 9 ++++++++-
src/features/packets/usePackets.ts | 2 ++
tests/features/channels/ChannelList.test.tsx | 8 ++++++++
tests/features/channels/MessagePanel.test.tsx | 20 ++++++++++++++++++-
.../observers/ObserverAdverts.test.tsx | 11 ++++++++++
tests/features/packets/PacketList.test.tsx | 20 ++++++++++++++++---
9 files changed, 74 insertions(+), 9 deletions(-)
diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx
index 9b293f9..c335228 100644
--- a/src/features/channels/ChannelList.tsx
+++ b/src/features/channels/ChannelList.tsx
@@ -253,7 +253,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
onSelect={handleSelect}
/>
)}
- {!keyedLoading && filteredChannels.length === 0 && (
+ {!keyedLoading && filteredChannels.length === 0 && !keyed.isError && !others.isError && (
{t("channels.noMatches")}
)}
{(keyed.isError || others.isError) && {t("channels.loadError")}
}
diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx
index ed4a3a7..542cf0d 100644
--- a/src/features/channels/MessagePanel.tsx
+++ b/src/features/channels/MessagePanel.tsx
@@ -223,7 +223,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
))}
- ) : (
+ ) : isError ? null : (
{t(!channel.keyKnown ? "channelMessages.emptyNoKey" : scope ? "channelMessages.emptyScope" : "channelMessages.empty", { scope })}
diff --git a/src/features/observers/ObserverAdverts.tsx b/src/features/observers/ObserverAdverts.tsx
index 1841977..44c0898 100644
--- a/src/features/observers/ObserverAdverts.tsx
+++ b/src/features/observers/ObserverAdverts.tsx
@@ -31,7 +31,7 @@ function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick: ()
export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: string; onAnalyzePacket: (hash: string, observationId: number) => void }) {
const { t } = useTranslation();
- const { data: adverts, isPending } = useQuery({
+ const { data: adverts, isPending, isError, isFetching, refetch } = useQuery({
queryKey: ["observer-adverts", observerId],
queryFn: () => getObserverAdverts(observerId, { limit: 50 }),
staleTime: 30_000,
@@ -41,7 +41,12 @@ export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: s
{t("observerPage.advertsHeard")}
{isPending ?
{t("common.loading")}
- : adverts?.items.length ? (
+ : isError && !adverts?.items.length ? (
+
+ {t("common.loadFailed")}{" · "}
+
+
+ ) : adverts?.items.length ? (
{adverts.items.map(a =>
onAnalyzePacket(a.packetHash, a.id)} />)}
diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx
index de11a41..1c829c2 100644
--- a/src/features/packets/PacketList.tsx
+++ b/src/features/packets/PacketList.tsx
@@ -70,6 +70,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
isFetchingNextPage,
isLoading,
isError,
+ refetch,
observersByHash,
handlePacketObservation,
handleLagged,
@@ -232,6 +233,12 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
{isLoading && packets.length === 0 ? (
+ ) : isError && allPackets.length === 0 ? (
+ // a failed history load is not an empty region: say so and offer a retry
+
+ {t("loadingPill.packets.failed")}{" · "}
+
+
) : (
0}
count={packets.length}
noun="packets"
position="bottom-3 right-3"
diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts
index 1dea70e..5734836 100644
--- a/src/features/packets/usePackets.ts
+++ b/src/features/packets/usePackets.ts
@@ -215,6 +215,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
isFetchingNextPage,
isLoading,
isError,
+ refetch,
} = useInfiniteQuery({
// The unfiltered key must stay 2-element so its cached entry survives filter toggling; the
// lagged/mount resets above match both shapes by prefix.
@@ -262,6 +263,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
isFetchingNextPage,
isLoading: isLoading || pending,
isError,
+ refetch,
observersByHash,
handlePacketObservation,
handleLagged,
diff --git a/tests/features/channels/ChannelList.test.tsx b/tests/features/channels/ChannelList.test.tsx
index bda0a6b..62a31c9 100644
--- a/tests/features/channels/ChannelList.test.tsx
+++ b/tests/features/channels/ChannelList.test.tsx
@@ -117,6 +117,14 @@ describe("channel directory", () => {
expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument();
});
+ it("shows the load error, not an empty list, when the first page fails", async () => {
+ keyed(new Error("unavailable"));
+ show();
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not load channels.");
+ expect(screen.queryByText("No matching channels loaded.")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Retry loading channels" })).toBeInTheDocument();
+ });
+
it("can retry an initial failure", async () => {
keyed(new Error("temporary failure"), page([channel(1)]));
show();
diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx
index af81366..d05e8c0 100644
--- a/tests/features/channels/MessagePanel.test.tsx
+++ b/tests/features/channels/MessagePanel.test.tsx
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { act, render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
import i18n from "../../../src/i18n";
+import { getChannelMessagesPage } from "../../../src/api/client";
// Older live WS messages have no id — preserve that compatible runtime shape.
const restMsg: ChannelMessage = {
@@ -149,3 +150,20 @@ describe("MessagePanel live follow", () => {
expect(scrollTo).toHaveBeenCalled();
});
});
+
+describe("MessagePanel load failure", () => {
+ it("shows the error and retry instead of the empty state", async () => {
+ await i18n.changeLanguage("en");
+ vi.mocked(getChannelMessagesPage).mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 }));
+ render(
+
+
+ ,
+ );
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not load messages.");
+ expect(screen.queryByText(/No messages/)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByText("live two")).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/features/observers/ObserverAdverts.test.tsx b/tests/features/observers/ObserverAdverts.test.tsx
index e0a124c..ba37bff 100644
--- a/tests/features/observers/ObserverAdverts.test.tsx
+++ b/tests/features/observers/ObserverAdverts.test.tsx
@@ -42,4 +42,15 @@ describe("ObserverAdverts", () => {
renderAdverts();
expect(await screen.findByText("No adverts heard")).toBeInTheDocument();
});
+
+ it("shows an error with a retry instead of the empty state when the request fails", async () => {
+ mockGetObserverAdverts.mockRejectedValueOnce(Object.assign(new Error("unavailable"), { status: 503 }));
+ mockGetObserverAdverts.mockResolvedValueOnce({ items: [advert(1, "Node Alpha")], nextCursor: null, hasMore: false });
+ renderAdverts();
+ expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
+ expect(screen.queryByText("No adverts heard")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByText("Node Alpha")).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
});
diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx
index 06f1405..2f56208 100644
--- a/tests/features/packets/PacketList.test.tsx
+++ b/tests/features/packets/PacketList.test.tsx
@@ -19,6 +19,7 @@ const basePackets = () => ({
isFetchingNextPage: false,
isLoading: false,
isError: false,
+ refetch: vi.fn(),
observersByHash: new Map(),
handlePacketObservation: () => {},
handleLagged: () => {},
@@ -180,11 +181,24 @@ describe("PacketList loading feedback", () => {
expect(screen.getByRole("status").textContent).toContain("Loading packets… (1)");
});
- it("surfaces a failed history fetch through the pill", () => {
- usePackets.mockImplementation(() => ({ ...basePackets(), isError: true }));
+ it("shows an error with a retry, not an empty list, when history fails with nothing loaded", () => {
+ const refetch = vi.fn();
+ usePackets.mockImplementation(() => ({ ...basePackets(), isError: true, refetch }));
renderList();
- expect(screen.getByRole("status").textContent).toContain("Failed to load packets");
+ expect(screen.getByRole("alert")).toHaveTextContent("Failed to load packets");
+ expect(screen.queryByTestId("expanded")).toBeNull();
+ expect(screen.queryByRole("status")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps loaded packets and surfaces a later history failure through the pill", () => {
+ usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("h1")], isError: true }));
+ renderList();
+
+ expect(screen.getByTestId("expanded")).toBeTruthy();
+ expect(screen.getByRole("status").textContent).toContain("Some packets failed to load (1 shown)");
});
it("renders neither pill nor skeletons when idle", () => {