commit 5163aefc675e52ff1bf2d7994414305726771d17
parent a2097c5df8fa99b153c9fcd4205f8c2fa276c166
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:44:58 -0400
hub: review fixes — an archive's posts/subs join only when it is ready; the answered line waits for every archive; focus rings; cancel an off archive's queued pages; "videos"
- An archive is ready only once its summaries AND its subs + posts manifests
have settled; the subs, posts and alias merges are keyed on the ready set, so
a failed archive contributes nothing and a loading one's posts no longer show
before its videos.
- "N of M archives answered." shows only when nothing in scope is loading
(so M = ready + failed); role="status"; "archive" for M = 1.
- focus-visible outline on the chip toggle, chip Retry and the line's Retry.
- The page queryFn consumes TanStack's signal: a page of an archive switched
off mid-load bails at the gate instead of downloading.
- The live line says "videos", the chips' word for the same figure.
- Retry refetches only this provider's feeds of that origin.
- e2e: the answered line waits for a still-loading third archive (+1).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 112 insertions(+), 50 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -223,6 +223,15 @@ export type FederatedSite = {
// not share a connection pool, so a global cap would only slow the whole hub.
const PAGE_FETCHES_PER_SITE = 6;
+// The per-archive feeds this provider fetches — what a Retry refetches.
+const FEDERATED_KEYS = new Set([
+ "manifest",
+ "summaries-page",
+ "subs-manifest",
+ "posts-manifest",
+ "search-aliases",
+]);
+
type Gate = { active: number; queue: Array<() => void> };
const pageGates = new Map<string, Gate>();
@@ -319,12 +328,46 @@ export function MultiSiteDataProvider({
const pageQueries = useQueries({
queries: pageDescriptors.map((d) => ({
queryKey: ["summaries-page", d.origin, d.index],
- queryFn: () =>
- gated(d.origin, () => readerFor(d.origin).readSummariesPage(d.index)),
+ // Consuming `signal` lets TanStack cancel a page whose archive was
+ // switched off mid-load; a page still waiting at the gate then bails
+ // before it is fetched.
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ gated(d.origin, () => {
+ signal.throwIfAborted();
+ return readerFor(d.origin).readSummariesPage(d.index);
+ }),
})),
});
- // 3. Each archive's state, from its manifest query and its page queries.
+ // Each archive's subs + posts manifests. Fetched alongside its summaries,
+ // merged below only once the archive is ready, so all of an archive's
+ // contributions land in the search in one re-run. A posts 404 (no posts
+ // corpus) resolves to an empty manifest, not a failure.
+ const subsQueries = useQueries({
+ queries: sites.map((s) => ({
+ queryKey: ["subs-manifest", s.origin],
+ queryFn: () => readerFor(s.origin).readSubsSiteManifest(),
+ enabled: inScope(s),
+ })),
+ });
+ const postsQueries = useQueries({
+ queries: sites.map((s) => ({
+ queryKey: ["posts-manifest", s.origin],
+ queryFn: () =>
+ readerFor(s.origin)
+ .readPostsSiteManifest()
+ .catch(
+ (): PostsManifest => ({
+ version: 0,
+ channels: [],
+ totalCount: 0,
+ generatedAt: "",
+ }),
+ ),
+ enabled: inScope(s),
+ })),
+ });
+ // 3. Each archive's state, from its manifest, page, subs and posts queries.
const pagesByOrigin = new Map<string, (typeof pageQueries)[number][]>();
pageQueries.forEach((q, i) => {
const origin = pageDescriptors[i]?.origin;
@@ -344,10 +387,16 @@ export function MultiSiteDataProvider({
const pages = pagesByOrigin.get(s.origin) ?? [];
const count = m?.data?.totalCount;
const withCount = count === undefined ? base : { ...base, count };
+ // A subs manifest that errored still counts as settled (the archive just
+ // has no live chat in the search); posts never error (404 → empty).
+ const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) =>
+ !!q && (q.isSuccess || (q.isError && !q.isFetching));
if (
m?.isSuccess &&
pages.length === m.data.pageCount &&
- pages.every((p) => p.isSuccess)
+ pages.every((p) => p.isSuccess) &&
+ settled(subsQueries[i]) &&
+ settled(postsQueries[i])
) {
return { ...withCount, status: "ready" };
}
@@ -457,22 +506,15 @@ export function MultiSiteDataProvider({
// 6. Merge subs manifests: concat channels (slug → origin-qualified) so the
// chat scope resolves per-origin; sum the live-chat/total counts. Merged
- // from whichever in-scope archives have answered so far.
- const subsQueries = useQueries({
- queries: sites.map((s) => ({
- queryKey: ["subs-manifest", s.origin],
- queryFn: () => readerFor(s.origin).readSubsSiteManifest(),
- enabled: inScope(s),
- })),
- });
+ // over the READY archives only (a failed archive contributes nothing).
const subsKey = sites
- .map((s, i) => (inScope(s) && subsQueries[i]?.data ? s.origin : ""))
+ .map((s, i) => (readyOrigins.has(s.origin) && subsQueries[i]?.data ? s.origin : ""))
.join("\u0000");
const subsManifest = useMemo<SubsManifest | null>(() => {
const loaded = sites
.map((s, i) => ({
origin: s.origin,
- data: inScope(s) ? subsQueries[i]?.data : undefined,
+ data: readyOrigins.has(s.origin) ? subsQueries[i]?.data : undefined,
}))
.filter((e): e is { origin: string; data: SubsManifest } => !!e.data);
if (loaded.length === 0) return null;
@@ -502,31 +544,14 @@ export function MultiSiteDataProvider({
// with no posts corpus 404s; treat that as an empty contribution so one
// video-only origin can't blank the hub's posts scope (and it is not a
// failure of that archive).
- const postsQueries = useQueries({
- queries: sites.map((s) => ({
- queryKey: ["posts-manifest", s.origin],
- queryFn: () =>
- readerFor(s.origin)
- .readPostsSiteManifest()
- .catch(
- (): PostsManifest => ({
- version: 0,
- channels: [],
- totalCount: 0,
- generatedAt: "",
- }),
- ),
- enabled: inScope(s),
- })),
- });
const postsKey = sites
- .map((s, i) => (inScope(s) && postsQueries[i]?.data ? s.origin : ""))
+ .map((s, i) => (readyOrigins.has(s.origin) && postsQueries[i]?.data ? s.origin : ""))
.join("\u0000");
const postsManifest = useMemo<PostsManifest | null>(() => {
const loaded = sites
.map((s, i) => ({
origin: s.origin,
- data: inScope(s) ? postsQueries[i]?.data : undefined,
+ data: readyOrigins.has(s.origin) ? postsQueries[i]?.data : undefined,
}))
.filter((e): e is { origin: string; data: PostsManifest } => !!e.data);
if (loaded.length === 0) return null;
@@ -560,12 +585,12 @@ export function MultiSiteDataProvider({
})),
});
const aliasesKey = sites
- .map((s, i) => (inScope(s) && aliasQueries[i]?.data ? s.origin : ""))
+ .map((s, i) => (readyOrigins.has(s.origin) && aliasQueries[i]?.data ? s.origin : ""))
.join("\u0000");
const aliases = useMemo<SearchAlias[]>(() => {
let merged: SearchAlias[] = [];
sites.forEach((s, i) => {
- const data = inScope(s) ? aliasQueries[i]?.data : undefined;
+ const data = readyOrigins.has(s.origin) ? aliasQueries[i]?.data : undefined;
if (data) merged = mergeAliases(merged, data);
});
return merged;
@@ -665,7 +690,9 @@ export function MultiSiteDataProvider({
(origin: string) => {
void queryClient.refetchQueries({
predicate: (q) =>
- q.queryKey[1] === origin && q.state.status === "error",
+ FEDERATED_KEYS.has(String(q.queryKey[0])) &&
+ q.queryKey[1] === origin &&
+ q.state.status === "error",
});
},
[queryClient],
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -324,14 +324,19 @@ export default function SearchResults() {
// above the results says how many did, names the ones that did not, and offers
// a Retry for them. The results from the archives that answered render as
// usual underneath — one member failing never empties the page. Nothing in
-// single-site mode (no `federation`), and nothing while every archive is fine
-// or still loading (the scope chips show loading).
+// single-site mode (no `federation`), nothing while every archive is fine, and
+// nothing while any archive in scope is still loading (the chips show that), so
+// "N of M" always names every archive it did not count.
function FederationStatus() {
const { federation } = useSearchData();
if (!federation) return null;
const scoped = federation.sites.filter((s) => s.status !== "off");
const failed = scoped.filter((s) => s.status === "failed");
- if (failed.length === 0) return null;
+ // Only once every archive in scope has settled, so the count is final and
+ // every archive not counted is named (the chips show what is still loading).
+ if (failed.length === 0 || scoped.some((s) => s.status === "loading")) {
+ return null;
+ }
const answered = scoped.filter((s) => s.status === "ready").length;
const names = failed.map((s) => s.siteTitle);
const list =
@@ -340,11 +345,13 @@ function FederationStatus() {
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
return (
<p
+ role="status"
data-testid="hub-scope-status"
className="mb-2 flex flex-wrap items-baseline gap-x-2 text-sm text-muted-foreground"
>
<span>
- {answered} of {scoped.length} archives answered. {list}{" "}
+ {answered} of {scoped.length}{" "}
+ {scoped.length === 1 ? "archive" : "archives"} answered. {list}{" "}
did not, so{" "}
{names.length === 1 ? "its" : "their"} videos are not in these results.
</span>
@@ -353,7 +360,7 @@ function FederationStatus() {
onClick={() => {
for (const s of failed) federation.retry(s.origin);
}}
- className="text-brand transition-colors hover:underline"
+ className="text-brand transition-colors hover:underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
>
Retry
</button>
diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx
@@ -64,7 +64,7 @@ export default function HubScope({
}
onClick={() => onToggle(s.origin)}
className={cn(
- "flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 select-none hover:bg-accent hover:text-accent-foreground",
+ "flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 select-none hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
on ? "text-foreground" : "text-muted-foreground",
)}
>
@@ -91,7 +91,7 @@ export default function HubScope({
onClick={() => retry(s.origin)}
aria-label={`Retry ${s.siteTitle}`}
title={s.error ? `${s.error} — try again` : "Try again"}
- className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent"
+ className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
>
Retry
</button>
diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx
@@ -23,10 +23,11 @@ export default function HubStats() {
const { sites } = useRegistry();
const { summariesState, channels, federation } = useSearchData();
// The archives in scope (the visitor's chips), and what has arrived from them:
- // channels as each manifest lands, transcripts as each archive is ready.
+ // channels as each manifest lands, videos as each archive is ready.
const archives =
federation?.sites.filter((s) => s.status !== "off").length ?? sites.length;
- const transcripts = summariesState.manifest?.totalCount ?? 0;
+ // The ready archives' record counts — the same figures the chips show.
+ const videos = summariesState.manifest?.totalCount ?? 0;
if (sites.length === 0) return null;
@@ -36,7 +37,7 @@ export default function HubStats() {
<span className="mx-2 text-muted-foreground/40">·</span>
{part(channels.length, "channel", "channels")}
<span className="mx-2 text-muted-foreground/40">·</span>
- {part(transcripts, "transcript", "transcripts")} right now.
+ {part(videos, "video", "videos")} right now.
</p>
);
}
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -24,6 +24,7 @@ async function fulfillJson(route: Route, body: unknown) {
type Member = { origin: string; title: string; channel: string; video: string };
const A: Member = { origin: ORIGIN_A, title: "Origin A", channel: "Channel A", video: "Alpha Video" };
const B: Member = { origin: ORIGIN_B, title: "Origin B", channel: "Channel B", video: "Bravo Video" };
+const C: Member = { origin: "http://localhost:4597", title: "Origin C", channel: "Channel C", video: "Charlie Video" };
function manifestOf(m: Member) {
return {
@@ -39,7 +40,7 @@ function manifestOf(m: Member) {
}
function pageOf(m: Member) {
- const id = m === A ? "vida1" : "vidb1";
+ const id = m === A ? "vida1" : m === B ? "vidb1" : "vidc1";
return [
{
slug: `chan/${id}`,
@@ -101,11 +102,11 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
return mock;
}
-async function setup(page: Page) {
+async function setup(page: Page, members: Member[] = [A, B]) {
await page.route("**/hub-sites.json", (r) =>
fulfillJson(
r,
- [A, B].map((m, i) => ({
+ members.map((m, i) => ({
siteId: `origin${i}`,
siteTitle: m.title,
siteUrl: m.origin,
@@ -117,7 +118,10 @@ async function setup(page: Page) {
await page.route("**/hub-summary.json", (r) =>
r.fulfill({ status: 404, body: "" }),
);
- return { a: await mockMember(page, A), b: await mockMember(page, B) };
+ const a = await mockMember(page, A);
+ const b = await mockMember(page, B);
+ const c = members.includes(C) ? await mockMember(page, C) : null;
+ return { a, b, c };
}
const chip = (page: Page, m: Member) =>
@@ -198,6 +202,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
chip(page, B).getByRole("button", { name: "Retry Origin B" }),
).toBeVisible();
await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await expect(status(page)).toHaveAttribute("role", "status");
await expect(status(page)).toContainText("1 of 2 archives answered.");
await expect(status(page)).toContainText("Origin B did not");
// The healthy member's results still render.
@@ -245,4 +250,26 @@ test.describe("hub federated search — scope, per-archive state, attribution",
await expect(resultFrom(page, B)).toHaveCount(1);
await expect(resultFrom(page, A)).toHaveCount(1);
});
+
+ test("the answered line waits until every archive in scope has settled", async ({
+ page,
+ }) => {
+ const mocks = await setup(page, [A, B, C]);
+ mocks.b.pages = "abort";
+ mocks.c!.pages = "hold";
+ await page.goto("/");
+
+ await expect(chip(page, B)).toHaveAttribute("data-status", "failed");
+ await expect(chip(page, C)).toHaveAttribute("data-status", "loading");
+ // Origin C is neither answered nor failed yet: no count that leaves it out.
+ await expect(status(page)).toHaveCount(0);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+
+ await expect.poll(() => mocks.c!.held.length).toBeGreaterThan(0);
+ for (const r of mocks.c!.held.splice(0)) await fulfillJson(r, pageOf(C));
+ await expect(chip(page, C)).toHaveAttribute("data-status", "ready");
+ await expect(status(page)).toContainText("2 of 3 archives answered.");
+ await expect(status(page)).toContainText("Origin B did not");
+ await expect(resultFrom(page, C)).toHaveCount(1);
+ });
});