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