From 44e01db1a1f5279d0a67b80d5305f710f600a84f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 1 Oct 2026 09:18:28 -0400 Subject: [PATCH] List keyed channels up front and load the rest on demand --- src/api/client.ts | 3 +- src/features/channels/ChannelList.tsx | 106 ++++++--- tests/api/client.test.ts | 9 + tests/features/channels/ChannelList.test.tsx | 227 ++++++++++--------- 4 files changed, 206 insertions(+), 139 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 389f9112..1b47bfff 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -126,7 +126,7 @@ export function getRegion(regionId: number): Promise { } // Preserve the server cursor rather than deriving it from the displayed channel order. -export function getChannels(params?: { iatas?: string[]; limit?: number; cursor?: number | string }): Promise { +export function getChannels(params?: { iatas?: string[]; limit?: number; cursor?: number | string; keyKnown?: boolean }): Promise { const iatas = params?.iatas ?? []; return request("/channels", { iata: iatas.length === 1 ? iatas[0] : undefined, @@ -134,6 +134,7 @@ export function getChannels(params?: { iatas?: string[]; limit?: number; cursor? limit: params?.limit ?? DEFAULT_PAGE_SIZE, cursor: typeof params?.cursor === "number" ? params.cursor : undefined, pageCursor: typeof params?.cursor === "string" ? params.cursor : undefined, + keyKnown: params?.keyKnown === undefined ? undefined : String(params.keyKnown), }); } diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index d4236b5f..f8fa6f83 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from "react"; import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getChannels } from "../../api/client"; import { isRateLimited, subscribeRateLimit } from "../../api/rate-limit"; @@ -20,6 +20,10 @@ interface ChannelListProps { onAnalyze: (hash: string | null) => void; } +// keyed channels are few, so they load in big pages; the rest page on demand +const KEYED_PAGE_SIZE = 200; +const nextChannelPage = (last: ChannelPage) => last.hasMore ? last.nextPageCursor ?? last.nextCursor ?? undefined : undefined; + function appendMessages(old: InfiniteData> | undefined, messages: ChannelMessage[]) { if (!old) return old; const known = new Set(old.pages.flatMap((page) => page.items.map((message) => message.packetHash))); @@ -40,6 +44,9 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { const [searchField, setSearchField] = useState("name"); const [keyFilter, setKeyFilter] = useState(""); const [hashtagFilter, setHashtagFilter] = useState(""); + // tied to the region it was opened in, so a region switch never fetches the new region's other channels + const [othersRegion, setOthersRegion] = useState(null); + const showOthers = othersRegion === regionKey; const queryClient = useQueryClient(); const refreshPending = useRef(false); const pendingMessages = useRef(new Map()); @@ -85,31 +92,53 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { } }, [regionKey]); - const { data, isLoading, isFetching, isError, fetchNextPage, hasNextPage, refetch } = useInfiniteQuery({ - queryKey: ["channels", regionKey], - queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam }), + const keyedKey = useMemo(() => ["channels", regionKey, "keyed"], [regionKey]); + const keyed = useInfiniteQuery({ + queryKey: keyedKey, + queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam, keyKnown: true, limit: KEYED_PAGE_SIZE }), initialPageParam: undefined as number | string | undefined, - getNextPageParam: (last) => last.hasMore ? last.nextPageCursor ?? last.nextCursor ?? undefined : undefined, + getNextPageParam: nextChannelPage, maxPages: MAX_INFINITE_PAGES, staleTime: 60_000, }); + const others = useInfiniteQuery({ + queryKey: ["channels", regionKey, "other"], + queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam, keyKnown: false }), + initialPageParam: undefined as number | string | undefined, + getNextPageParam: nextChannelPage, + maxPages: MAX_INFINITE_PAGES, + staleTime: 60_000, + enabled: showOthers || keyFilter === "unknown", + }); + + // A server without the keyKnown filter mixes unkeyed channels in; don't page through all of them. + const keyedUnfiltered = keyed.data?.pages.some((p) => p.items.some((ch) => !ch.keyKnown)) ?? false; + const { hasNextPage: keyedHasMore, isFetching: keyedFetching, isError: keyedError, fetchNextPage: fetchKeyed } = keyed; + useEffect(() => { + if (keyedHasMore && !keyedFetching && !keyedError && !keyedUnfiltered) void fetchKeyed(); + }, [keyedHasMore, keyedFetching, keyedError, keyedUnfiltered, fetchKeyed]); useEffect(() => { - if (!isFetching && refreshPending.current) { + if (!keyedFetching && refreshPending.current) { refreshPending.current = false; - void queryClient.resetQueries({ queryKey: ["channels", regionKey], exact: true }); + void queryClient.resetQueries({ queryKey: keyedKey, exact: true }); } - }, [isFetching, queryClient, regionKey]); + }, [keyedFetching, queryClient, keyedKey]); const channels = useMemo(() => { const byId = new Map(); - for (const page of data?.pages ?? []) { + for (const page of keyed.data?.pages ?? []) { + for (const channel of page.items) { + if (channel.keyKnown && !byId.has(channel.id)) byId.set(channel.id, channel); + } + } + for (const page of others.data?.pages ?? []) { for (const channel of page.items) { if (!byId.has(channel.id)) byId.set(channel.id, channel); } } return [...byId.values()]; - }, [data]); + }, [keyed.data, others.data]); const handleSelect = useCallback((id: number) => { pendingMessages.current.clear(); @@ -124,10 +153,11 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { setMessageScope(scope); }, []); - // "Public" pinned first, then named channels, then unnamed by most recent + // keyed first; within each, "Public" pinned first, then named channels, then unnamed by most recent const sortedChannels = useMemo( () => [...channels].sort((a, b) => { + if (a.keyKnown !== b.keyKnown) return a.keyKnown ? -1 : 1; const aPub = a.name === "Public" ? 1 : 0; const bPub = b.name === "Public" ? 1 : 0; if (aPub !== bPub) return bPub - aPub; @@ -147,22 +177,22 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { const handleChannelMessage = useCallback( (data: ChannelMessage) => { - const key = ["channels", regionKey]; - const cached = queryClient.getQueryData>(key); - const cachedChannels = cached?.pages.flatMap((p) => p.items) ?? []; + const listKey = ["channels", regionKey]; + const cachedChannels = queryClient.getQueriesData>({ queryKey: listKey }) + .flatMap(([, cached]) => cached?.pages.flatMap((p) => p.items) ?? []); const matchesChannel = (ch: ChannelSummary) => data.channelId !== undefined ? ch.id === data.channelId : ch.channelHash === data.channelHash; const known = cachedChannels.find(matchesChannel); if (known) { // Display timestamps may change; the server's page cursors must not. - queryClient.setQueryData>(key, (old) => old && ({ + queryClient.setQueriesData>({ queryKey: listKey }, (old) => old && ({ ...old, pages: old.pages.map((p) => ({ ...p, items: p.items.map((ch) => ch.id === known.id ? { ...ch, lastSeen: Math.max(ch.lastSeen, data.sentAt) } : ch) })), })); } else if (!isRateLimited()) { - // An unknown live channel needs a fresh first page, not a replay of every loaded page. - if (queryClient.isFetching({ queryKey: key, exact: true })) refreshPending.current = true; - else void queryClient.resetQueries({ queryKey: key, exact: true }); + // A decrypted message means a keyed channel; refetch that list from its first page. + if (queryClient.isFetching({ queryKey: keyedKey, exact: true })) refreshPending.current = true; + else void queryClient.resetQueries({ queryKey: keyedKey, exact: true }); } const selected = cachedChannels.find((ch) => ch.id === selectedId) ?? selection; @@ -186,7 +216,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { ); } }, - [queryClient, selectedId, selection, regionKey, messageScope, messageKey, flushPending], + [queryClient, selectedId, selection, regionKey, keyedKey, messageScope, messageKey, flushPending], ); useWsChannelMessageHandler(wsManager, handleChannelMessage); @@ -211,7 +241,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
{showList && (
- {isLoading ? ( + {keyed.isLoading ? ( ) : ( )} - {!isLoading && filteredChannels.length === 0 && ( + {!keyed.isLoading && filteredChannels.length === 0 && (

No matching channels loaded.

)} - {isError &&

Could not load channels.

} - {(hasNextPage || isError) && ( - + {(keyed.isError || others.isError) &&

Could not load channels.

} + {keyed.isError ? ( + void keyed.refetch()}> + {keyed.isFetching ? "Loading channels..." : "Retry loading channels"} + + ) : !others.isEnabled ? ( + !keyed.isLoading && setOthersRegion(regionKey)}>Load other channels + ) : (others.hasNextPage || others.isError) && ( + void (others.hasNextPage ? others.fetchNextPage() : others.refetch())}> + {others.isFetching ? "Loading channels..." : others.isError ? "Retry loading channels" : "Load more channels"} + )}
)} @@ -252,3 +283,16 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
); } + +function PagerButton({ disabled, onClick, children }: { disabled?: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ); +} diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 9285f3a4..66660825 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -252,6 +252,15 @@ describe("getChannels", () => { expect(url.searchParams.has("iata")).toBe(false); }); + it.each([true, false])("sends keyKnown=%s and omits it when unset", async (keyKnown) => { + const getUrl = mockFetchOnce({ items: [] }); + await getChannels({ keyKnown }); + expect(new URL(getUrl()).searchParams.get("keyKnown")).toBe(String(keyKnown)); + const getUnset = mockFetchOnce({ items: [] }); + await getChannels(); + expect(new URL(getUnset()).searchParams.has("keyKnown")).toBe(false); + }); + it("omits both iata params for all regions", async () => { const getUrl = mockFetchOnce({ items: [] }); diff --git a/tests/features/channels/ChannelList.test.tsx b/tests/features/channels/ChannelList.test.tsx index 3850f9eb..bda0a6b6 100644 --- a/tests/features/channels/ChannelList.test.tsx +++ b/tests/features/channels/ChannelList.test.tsx @@ -18,28 +18,48 @@ vi.mock("../../../src/features/channels/MessagePanel", () => ({ MessagePanel: ({ channel }: { channel: ChannelSummary | null }) =>
{channel?.id ?? "none"}
, })); -const channel = (id: number, name = `Channel ${id}`): ChannelSummary => ({ - id, name, channelHash: id.toString(16).padStart(2, "0"), lastSeen: 10000 - id, isHashtag: false, keyKnown: true, +const channel = (id: number, name = `Channel ${id}`, keyKnown = true): ChannelSummary => ({ + id, name, channelHash: id.toString(16).padStart(2, "0"), lastSeen: 10000 - id, isHashtag: false, keyKnown, }); +const other = (id: number, name = `Other ${id}`) => channel(id, name, false); const page = (items: ChannelSummary[], nextCursor: number | null = null): CursorPage => ({ items, nextCursor, hasMore: nextCursor !== null }); const response = (data: CursorPage) => new Response(JSON.stringify(data), { status: 200 }); -const first = () => page(Array.from({ length: 50 }, (_, i) => channel(i + 1)), 5000); const fetchMock = vi.fn(); +// Each list (keyKnown=true / false) answers from its own queue; an unqueued request gets an empty page. +type Reply = CursorPage | Error | Promise; +let queues: Record; +const keyed = (...replies: Reply[]) => queues.true!.push(...replies); +const others = (...replies: Reply[]) => queues.false!.push(...replies); +const calls = (kind: "true" | "false") => fetchMock.mock.calls.map(([url]) => new URL(String(url))).filter((url) => url.searchParams.get("keyKnown") === kind); +const lastCall = (kind: "true" | "false") => calls(kind).at(-1)!; + function show(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { const app = () => {}} />; return { ...render(app()), client, app }; } -function lastURL() { return new URL(String(fetchMock.mock.calls.at(-1)![0])); } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } +function pending() { + let resolve!: (value: Response) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} beforeEach(() => { region = { iatas: ["YYZ"], regionKey: "YYZ" }; + queues = { true: [], false: [] }; fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const reply = queues[new URL(String(input)).searchParams.get("keyKnown") ?? ""]?.shift() ?? page([]); + if (reply instanceof Error) throw reply; + if (reply instanceof Promise) return reply; + return response(reply); + }); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { @@ -47,147 +67,140 @@ afterEach(() => { noteRequestOk(); }); -describe("channel directory paging", () => { - it("prefers the precise server cursor and preserves it through live updates", async () => { +describe("channel directory", () => { + it("lists every keyed channel up front and leaves the rest behind a button", async () => { + keyed(page([channel(1, "Public"), channel(2, "First")], 5000), page([channel(3, "Last")])); + show(); + await screen.findByText("#Last"); + expect(screen.getByText("Public")).toBeInTheDocument(); + expect(calls("true")).toHaveLength(2); + expect(calls("true")[0]!.searchParams.get("limit")).toBe("200"); + expect(calls("true")[1]!.searchParams.get("cursor")).toBe("5000"); + expect(calls("true")[0]!.searchParams.get("iata")).toBe("YYZ"); + expect(calls("false")).toHaveLength(0); + expect(screen.getByRole("button", { name: "Load other channels" })).toBeInTheDocument(); + }); + + it("loads other channels on demand below the keyed ones, then pages them with the precise cursor", async () => { const precise = "v1:1700000000000123:50"; - const firstPage = { ...first(), nextPageCursor: precise }; - fetchMock.mockResolvedValueOnce(response(firstPage)) - .mockResolvedValueOnce(response(page([channel(51, "Precise result")]))); + keyed(page([channel(1)])); + others({ ...page([other(60)], 5000), nextPageCursor: precise }, page([other(61, "Older other")])); show(); - await screen.findByText("#Channel 1"); - act(() => onMessage({ id: 1, packetHash: "fixture", channelHash: "01", senderName: "fixture", content: "fixture", sentAt: 20000 })); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + await screen.findByText("#Other 60"); + const names = screen.getAllByRole("button", { name: /^#/ }).map((b) => b.textContent); + expect(names.findIndex((n) => n?.includes("Channel 1"))).toBeLessThan(names.findIndex((n) => n?.includes("Other 60"))); fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); - await screen.findByText("#Precise result"); - expect(lastURL().searchParams.get("pageCursor")).toBe(precise); - expect(lastURL().searchParams.has("cursor")).toBe(false); - expect(fetchMock).toHaveBeenCalledTimes(2); + await screen.findByText("#Older other"); + expect(lastCall("false").searchParams.get("pageCursor")).toBe(precise); + expect(lastCall("false").searchParams.get("limit")).toBe("50"); + expect(screen.queryByRole("button", { name: /Load (more|other) channels/ })).not.toBeInTheDocument(); }); - it("can retry an initial failure even without a page cursor", async () => { - fetchMock.mockRejectedValueOnce(new Error("temporary failure")) - .mockResolvedValueOnce(response(page([]))); - const { client } = show(); - await waitFor(() => expect(client.getQueryState(["channels", "YYZ"])?.status).toBe("error")); - fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); - await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(lastURL().searchParams.has("cursor")).toBe(false); - expect(screen.queryByRole("button", { name: "Load more channels" })).not.toBeInTheDocument(); + it("stops paging keyed channels when the server ignores keyKnown", async () => { + keyed(page([channel(1, "Keyed"), other(2, "Mixed in")], 5000)); + show(); + await screen.findByText("#Keyed"); + await waitFor(() => expect(screen.getByRole("button", { name: "Load other channels" })).toBeInTheDocument()); + expect(calls("true")).toHaveLength(1); + expect(screen.queryByText("#Mixed in")).not.toBeInTheDocument(); }); - it("finds older matches one page at a time and keeps the selected channel when filtered out", async () => { - fetchMock.mockResolvedValueOnce(response(first())) - .mockResolvedValueOnce(response(page([channel(51, "Older match")]))); + it("loads other channels automatically for the Unknown key filter", async () => { + keyed(page([channel(1)])); + others(page([other(60)])); show(); - await screen.findByText("#Channel 50"); - expect(fetchMock).toHaveBeenCalledTimes(1); - fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "older match" } }); - await screen.findByText("No matching channels loaded."); - fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); - fireEvent.click(await screen.findByRole("button", { name: /#Older match/ })); - expect(screen.getByTestId("selected-channel")).toHaveTextContent("51"); - expect(lastURL().searchParams.get("cursor")).toBe("5000"); - expect(lastURL().searchParams.get("iata")).toBe("YYZ"); - expect(lastURL().searchParams.get("limit")).toBe("50"); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(screen.queryByRole("button", { name: "Load more channels" })).not.toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "absent" } }); - await screen.findByText("No matching channels loaded."); - expect(screen.getByTestId("selected-channel")).toHaveTextContent("51"); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByRole("button", { name: /^Key/ })); + fireEvent.click(screen.getByRole("option", { name: "Unknown" })); + await screen.findByText("#Other 60"); + expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument(); + }); + + it("can retry an initial failure", async () => { + keyed(new Error("temporary failure"), page([channel(1)])); + show(); + fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); + await screen.findByText("#Channel 1"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(calls("true")).toHaveLength(2); + expect(calls("true")[1]!.searchParams.has("cursor")).toBe(false); }); - it("gates a pending page, offers retry after failure and trusts a full final page", async () => { - let rejectPage!: (error: Error) => void; - fetchMock.mockResolvedValueOnce(response(first())) - .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectPage = reject; })) - .mockResolvedValueOnce(response(page(Array.from({ length: 50 }, (_, i) => channel(i + 51))))); + it("gates a pending page of other channels and offers retry after failure", async () => { + const slow = pending(); + keyed(page([channel(1)])); + others(page([other(60)], 5000), slow.promise, page([other(61)])); show(); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); fireEvent.click(await screen.findByRole("button", { name: "Load more channels" })); - const pending = await screen.findByRole("button", { name: "Loading channels..." }); - expect(pending).toBeDisabled(); - fireEvent.click(pending); - expect(fetchMock).toHaveBeenCalledTimes(2); - await act(async () => rejectPage(new Error("temporary failure"))); + const loading = await screen.findByRole("button", { name: "Loading channels..." }); + expect(loading).toBeDisabled(); + fireEvent.click(loading); + expect(calls("false")).toHaveLength(2); + await act(async () => slow.reject(new Error("temporary failure"))); fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); - await screen.findByText("#Channel 100"); - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(lastURL().searchParams.get("cursor")).toBe("5000"); - expect(screen.queryByRole("button", { name: "Load more channels" })).not.toBeInTheDocument(); + await screen.findByText("#Other 61"); + expect(lastCall("false").searchParams.get("cursor")).toBe("5000"); }); - it("deduplicates IDs, preserves hash collisions and resets only the first page for an unseen live channel", async () => { - const older = channel(51, "Older selected"); - fetchMock.mockResolvedValueOnce(response(first())) - .mockResolvedValueOnce(response(page([channel(1), { ...channel(52), channelHash: "01" }, older], 4000))) - .mockResolvedValueOnce(response(first())); + it("updates a known channel in place and refreshes only the keyed list for an unseen live channel", async () => { + keyed(page([channel(1)]), page([channel(1), channel(99, "New live channel")])); + others(page([other(60)], 5000)); const { client } = show(); - fireEvent.click(await screen.findByRole("button", { name: "Load more channels" })); - fireEvent.click(await screen.findByRole("button", { name: /#Older selected/ })); - expect(screen.getAllByText("#Channel 1")).toHaveLength(1); - expect(screen.getByText("#Channel 52")).toBeInTheDocument(); - const event = { id: 1, packetHash: "fixture", channelHash: older.channelHash, senderName: "fixture", content: "fixture", sentAt: 20000 }; + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + fireEvent.click(await screen.findByRole("button", { name: /#Other 60/ })); + const event = { id: 1, packetHash: "fixture", channelHash: other(60).channelHash, senderName: "fixture", content: "fixture", sentAt: 20000 }; act(() => onMessage(event)); - const cached = client.getQueryData<{ pages: CursorPage[]; pageParams: unknown[] }>(["channels", "YYZ"])!; - expect(cached.pages[1]!.items.find(ch => ch.id === 51)?.lastSeen).toBe(20000); - expect(cached.pages[1]!.nextCursor).toBe(4000); - expect(cached.pageParams).toEqual([undefined, 5000]); + const cached = client.getQueryData>>(["channels", "YYZ", "other"])!; + expect(cached.pages[0]!.items[0]!.lastSeen).toBe(20000); + expect(cached.pages[0]!.nextCursor).toBe(5000); expect(fetchMock).toHaveBeenCalledTimes(2); act(() => onMessage({ ...event, channelHash: "ff" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); - await waitFor(() => expect(client.getQueryData<{ pages: unknown[] }>(["channels", "YYZ"])?.pages).toHaveLength(1)); - expect(lastURL().searchParams.has("cursor")).toBe(false); - expect(screen.getByTestId("selected-channel")).toHaveTextContent("51"); + await screen.findByText("#New live channel"); + expect(calls("true")).toHaveLength(2); + expect(calls("false")).toHaveLength(1); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("60"); }); - it("coalesces unknown live channels during paging into one first-page refresh", async () => { - let resolvePage!: (response: Response) => void; - fetchMock.mockResolvedValueOnce(response(first())) - .mockImplementationOnce(() => new Promise(resolve => { resolvePage = resolve; })) - .mockResolvedValueOnce(response(page([channel(99, "New live channel")]))); + it("coalesces unseen live channels during a keyed fetch into one refresh", async () => { + const slow = pending(); + keyed(page([channel(1)], 5000), slow.promise, page([channel(99, "New live channel")])); show(); - fireEvent.click(await screen.findByText("#Channel 1")); - fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); - await screen.findByRole("button", { name: "Loading channels..." }); + await screen.findByText("#Channel 1"); const event = { id: 1, packetHash: "fixture", channelHash: "ff", senderName: "fixture", content: "fixture", sentAt: 20000 }; act(() => { onMessage(event); onMessage({ ...event, channelHash: "fe" }); }); - expect(fetchMock).toHaveBeenCalledTimes(2); - await act(async () => resolvePage(response(page([channel(51)], 4000)))); + expect(calls("true")).toHaveLength(2); + await act(async () => slow.resolve(response(page([channel(2)])))); await screen.findByText("#New live channel"); - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(lastURL().searchParams.has("cursor")).toBe(false); - expect(screen.getByTestId("selected-channel")).toHaveTextContent("1"); + expect(calls("true")).toHaveLength(3); + expect(lastCall("true").searchParams.has("cursor")).toBe(false); }); - it("keeps the open channel when the page cap evicts it, then clears selection and filters on region change", async () => { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - client.setQueryData(["channels", "YYZ"], { - pages: Array.from({ length: 20 }, (_, i) => page([channel(i + 1)], 9000 - i)), - pageParams: Array.from({ length: 20 }, (_, i) => i === 0 ? undefined : 9001 - i), - }); - fetchMock.mockResolvedValueOnce(response(page([channel(21)], 8000))) - .mockResolvedValueOnce(response(page([channel(101, "Other region")]))); - const view = show(client); - fireEvent.click(await screen.findByText("#Channel 1")); - fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); - await screen.findByText("#Channel 21"); - expect(client.getQueryData<{ pages: unknown[] }>(["channels", "YYZ"])?.pages).toHaveLength(20); - expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument(); - expect(screen.getByTestId("selected-channel")).toHaveTextContent("1"); + it("keeps the open channel when it drops out of the list, then resets everything on region change", async () => { + keyed(page([channel(1)])); + others(page([other(60)])); + const view = show(); + fireEvent.click(await screen.findByRole("button", { name: "Load other channels" })); + fireEvent.click(await screen.findByRole("button", { name: /#Other 60/ })); fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "absent" } }); await screen.findByText("No matching channels loaded."); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("60"); + keyed(page([channel(101, "Other region")])); region = { iatas: ["YOW"], regionKey: "YOW" }; view.rerender(view.app()); await screen.findByText("#Other region"); expect(screen.getByTestId("selected-channel")).toHaveTextContent("none"); expect(screen.getByPlaceholderText("Search by name...")).toHaveValue(""); - expect(lastURL().searchParams.get("iata")).toBe("YOW"); - expect(lastURL().searchParams.has("cursor")).toBe(false); + expect(lastCall("true").searchParams.get("iata")).toBe("YOW"); + expect(screen.getByRole("button", { name: "Load other channels" })).toBeInTheDocument(); + expect(calls("false")).toHaveLength(1); }); }); describe("channel message overflow recovery", () => { it("recovers a rate-limit-stuck overflow once the rate limit clears", async () => { - fetchMock.mockResolvedValueOnce(response(first())); + keyed(page([channel(1)])); const { client } = show(); await screen.findByText("#Channel 1"); fireEvent.click(screen.getByText("#Channel 1")); @@ -209,7 +222,7 @@ describe("channel message overflow recovery", () => { }); it("invalidates an idle overflow directly from the WS handler once a further message arrives", async () => { - fetchMock.mockResolvedValueOnce(response(first())); + keyed(page([channel(1)])); const { client } = show(); await screen.findByText("#Channel 1"); fireEvent.click(screen.getByText("#Channel 1"));