diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx
index 68baec50..17b6a3cd 100644
--- a/frontend/src/components/ActiveFilterChips.tsx
+++ b/frontend/src/components/ActiveFilterChips.tsx
@@ -9,6 +9,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
+import { ChipLabelPending } from "./ui/Skeleton";
import { SURFACE } from "./ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
@@ -26,6 +27,16 @@ import { SURFACE } from "./ui/surfaces";
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
+// A chip's text: its name, or a placeholder while the name is on its way. The visible chip, the
+// overflow popover's row and the hidden measuring mirror all draw it, so the mirror measures what shows.
+function ChipText({ f, className }: { f: ActiveFilter; className: string }) {
+ return f.pending ? (
+
+ ) : (
+ {f.label}
+ );
+}
+
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
@@ -37,7 +48,7 @@ function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
onClick={f.remove}
removeLabel={t("feed.chips.remove", { name: f.label })}
>
- {f.label}
+
);
}
@@ -93,7 +104,7 @@ function OverflowPopover({
label={f.label}
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
>
- {f.label}
+
))}
@@ -134,7 +145,7 @@ export default function ActiveFilterChips({
const n = filters.length;
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
- const chipsKey = filters.map((f) => f.key + f.label).join("|");
+ const chipsKey = filters.map((f) => f.key + f.label + (f.pending ? "…" : "")).join("|");
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
@@ -191,7 +202,7 @@ export default function ActiveFilterChips({
}}
className={chipClass("token", "md") + " pr-1.5"}
>
- {f.label}
+
))}
diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx
index 495a5161..619fb7fe 100644
--- a/frontend/src/components/ChannelPicker.tsx
+++ b/frontend/src/components/ChannelPicker.tsx
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
-import { useQuery } from "@tanstack/react-query";
-import { qk } from "../lib/queryKeys";
-import { api, type FeedFilters } from "../lib/api";
+import type { FeedFilters } from "../lib/api";
+import { useChannelNames } from "../lib/useChannelNames";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
@@ -19,18 +18,13 @@ export default function ChannelPicker({
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
- const channelsQuery = useQuery({
- queryKey: qk.channels(),
- queryFn: api.channels,
- staleTime: 5 * 60_000,
- });
+ const { query: channelsQuery, names, pending } = useChannelNames();
const all = channelsQuery.data ?? [];
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
- // narrowing ever drops it from the visible rows.
+ // narrowing ever drops it from the visible rows, and names a reloaded filter before the list lands.
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
- const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
return (
void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")}
- selectedLabels={selectedLabels}
+ selectedLabels={names}
+ labelsPending={pending}
/>
);
}
diff --git a/frontend/src/components/channelChipNames.dom.test.tsx b/frontend/src/components/channelChipNames.dom.test.tsx
new file mode 100644
index 00000000..9dccbdaf
--- /dev/null
+++ b/frontend/src/components/channelChipNames.dom.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+//
+// A channel filter's chips are named from the subscription list, which a reload has not fetched yet:
+// they used to read as raw UC… ids for a moment (the owner, 2026-10-03). The names seen last time now
+// name them until the list lands; a channel never named on this browser draws a placeholder instead.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement, type ReactNode } from "react";
+import { api, type FeedFilters, type ManagedChannel } from "../lib/api";
+import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
+import { accountKey, LS } from "../lib/storage";
+import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
+import ActiveFilterChips from "./ActiveFilterChips";
+import ChannelPicker from "./ChannelPicker";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+
+const KNOWN = "UCknown0000000000000000";
+const NEW = "UCnever00000000000000000";
+const filters: FeedFilters = {
+ ...clearedFilters({ q: "" } as FeedFilters),
+ channelIds: [KNOWN, NEW],
+};
+const namesKey = () => accountKey(LS.channelNames, 5)!;
+
+let resolveChannels: (c: ManagedChannel[]) => void;
+let qc: QueryClient;
+const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: qc }, children);
+
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
+ localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ vi.spyOn(api, "tags").mockResolvedValue([] as never);
+ vi.spyOn(api, "channels").mockReturnValue(
+ new Promise((r) => {
+ resolveChannels = r;
+ }),
+ );
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const land = async () => {
+ await act(async () => {
+ resolveChannels([
+ { id: KNOWN, title: "Renamed since" },
+ { id: NEW, title: "Brand new" },
+ ] as ManagedChannel[]);
+ });
+};
+
+describe("a channel filter's chip names", () => {
+ it("name a remembered channel before the list lands, and hold the rest as pending", async () => {
+ const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
+ const chip = (id: string) => result.current.find((f) => f.key === `channel:${id}`)!;
+ expect(chip(KNOWN)).toMatchObject({ label: "Remembered name", pending: false });
+ expect(chip(NEW)).toMatchObject({ label: NEW, pending: true });
+
+ await land();
+ await waitFor(() => expect(chip(NEW)).toMatchObject({ label: "Brand new", pending: false }));
+ // The list wins over the remembered copy, and replaces it for the next reload.
+ expect(chip(KNOWN).label).toBe("Renamed since");
+ expect(JSON.parse(localStorage.getItem(namesKey())!)).toEqual({
+ [KNOWN]: "Renamed since",
+ [NEW]: "Brand new",
+ });
+ });
+
+ it("draw a placeholder above the feed, not the raw id", () => {
+ const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
+ const { container } = render(
+ {}} />,
+ );
+ expect(screen.getAllByText("Remembered name").length).toBeGreaterThan(0);
+ // The id is only a screen reader's (sr-only), beside a bone; nothing paints it.
+ for (const el of screen.getAllByText(NEW)) expect(el.className).toBe("sr-only");
+ expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
+ });
+
+ it("draw a placeholder in the filter panel's picker, then the name", async () => {
+ const { container } = render( {}} />, {
+ wrapper,
+ });
+ screen.getByText("Remembered name");
+ expect(screen.getByText(NEW).className).toBe("sr-only");
+ expect(container.querySelectorAll(".animate-pulse")).toHaveLength(1);
+
+ await land();
+ await waitFor(() => screen.getByText("Brand new"));
+ expect(screen.queryByText(NEW)).toBeNull();
+ expect(container.querySelectorAll(".animate-pulse")).toHaveLength(0);
+ });
+
+ it("fall back to the id when the list fails, so each chip stays told apart", async () => {
+ localStorage.removeItem(namesKey());
+ vi.spyOn(api, "channels").mockRejectedValue(new Error("down"));
+ const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
+ await waitFor(() =>
+ expect(result.current.find((f) => f.key === `channel:${NEW}`)).toMatchObject({
+ label: NEW,
+ pending: false,
+ }),
+ );
+ });
+});
diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx
index a7a16ef1..8019acbf 100644
--- a/frontend/src/components/ui/Combobox.tsx
+++ b/frontend/src/components/ui/Combobox.tsx
@@ -11,6 +11,7 @@ import { InnerInput } from "./Input";
import { DismissibleChip } from "./Chip";
import { fieldClass } from "./fieldStyles";
import { SURFACE } from "./surfaces";
+import { ChipLabelPending } from "./Skeleton";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
@@ -72,6 +73,9 @@ export interface ComboboxProps {
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record;
+ /** The labels are still on their way: a selected value with none yet draws a placeholder chip
+ * instead of its raw value (a channel filter's UC… id after a reload). */
+ labelsPending?: boolean;
}
export default function Combobox({
@@ -93,6 +97,7 @@ export default function Combobox({
maxVisible = 50,
renderOption,
selectedLabels,
+ labelsPending = false,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -113,7 +118,8 @@ export default function Combobox({
useEffect(() => {
for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
}, [options]);
- const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v;
+ const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v];
+ const labelFor = (v: string) => knownLabel(v) ?? v;
const setOpenAnd = (next: boolean) => {
if (next && disabled) return;
@@ -210,7 +216,11 @@ export default function Combobox({
onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))}
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
>
- {labelFor(v)}
+ {labelsPending && knownLabel(v) === undefined ? (
+
+ ) : (
+ labelFor(v)
+ )}
))}
);
}
+/** A chip whose name is still on its way: a name-sized bone, not the raw value (a channel's UC… id).
+ * A screen reader still gets the value, so two such chips stay told apart. */
+export function ChipLabelPending({ value }: { value: string }) {
+ return (
+ <>
+
+ {value}
+ >
+ );
+}
+
export type SkeletonShape = "grid" | "poster" | "list" | "table" | "text";
const COUNT: Record = { grid: 8, poster: 12, list: 6, table: 8, text: 5 };
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index 1a2feee7..82fe7d7d 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -41,6 +41,9 @@ export const LS = {
channelCardSize: "siftlode.channelCardSize",
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
+ // Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
+ // before the subscription list has landed. A cache, rewritten from the list on every load.
+ channelNames: "siftlode.channelNames",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
// statsTab below.
diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts
index 08fe89c2..44a5520b 100644
--- a/frontend/src/lib/useActiveFilters.ts
+++ b/frontend/src/lib/useActiveFilters.ts
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
-import { useQuery } from "@tanstack/react-query";
-import { qk } from "./queryKeys";
-import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
+import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
+import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort";
import { useTags } from "./useTags";
@@ -9,6 +8,8 @@ import { useTags } from "./useTags";
export interface ActiveFilter {
key: string;
label: string;
+ /** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
+ pending?: boolean;
remove: () => void;
}
@@ -49,12 +50,7 @@ export function useActiveFilters(
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useTags();
- const channelsQuery = useQuery({
- queryKey: qk.channels(),
- queryFn: api.channels,
- enabled: filters.channelIds.length > 0,
- staleTime: 5 * 60_000,
- });
+ const channelNames = useChannelNames(filters.channelIds.length > 0);
const out: ActiveFilter[] = [];
@@ -113,9 +109,11 @@ export function useActiveFilters(
for (const id of filters.channelIds) {
out.push({
key: `channel:${id}`,
- // Fall back to the id, not a generic label: several chips reading "This channel" while
- // the name cache loads would be indistinguishable, and each ✕ drops a different one.
- label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
+ // Fall back to the id, not a generic label: several chips reading "This channel" would be
+ // indistinguishable, and each ✕ drops a different one. While the list loads, a channel this
+ // browser has never named draws a placeholder instead (the id stays its accessible name).
+ label: channelNames.names[id] ?? id,
+ pending: channelNames.names[id] === undefined && channelNames.pending,
remove: () =>
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
});
diff --git a/frontend/src/lib/useChannelNames.ts b/frontend/src/lib/useChannelNames.ts
new file mode 100644
index 00000000..7cbbd18d
--- /dev/null
+++ b/frontend/src/lib/useChannelNames.ts
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { qk } from "./queryKeys";
+import { api, type ManagedChannel } from "./api";
+import { LS, readAccount, writeAccount } from "./storage";
+
+type Names = Record;
+
+const namesOf = (channels: ManagedChannel[]): Names =>
+ Object.fromEntries(channels.map((c) => [c.id, c.title ?? c.id]));
+
+const readNames = (): Names => {
+ const raw = readAccount(LS.channelNames, {});
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Names) : {};
+};
+
+/**
+ * Channel id → name, for the chips that name a channel filter (the filter panel's picker and the chip
+ * row above the feed). The names come from the subscription list, which a reload has not fetched yet:
+ * for a moment the chips read as raw UC… ids (the owner, 2026-10-03). The names seen last time are
+ * kept per account and name the chips until the list lands; the list then wins and replaces the copy,
+ * so a renamed or dropped channel heals on the next load.
+ *
+ * `pending` is true while the list loads: a channel this browser has never named then has no name to
+ * show, and the chip draws a placeholder rather than its id. A failed list leaves the id (the chips
+ * stay told apart, and each ✕ drops a different one).
+ */
+export function useChannelNames(enabled = true) {
+ const query = useQuery({
+ queryKey: qk.channels(),
+ queryFn: api.channels,
+ enabled,
+ staleTime: 5 * 60_000,
+ });
+ const [remembered] = useState(readNames);
+ useEffect(() => {
+ if (query.data) writeAccount(LS.channelNames, namesOf(query.data));
+ }, [query.data]);
+ return {
+ query,
+ names: query.data ? namesOf(query.data) : remembered,
+ pending: enabled && query.isPending,
+ };
+}