commit 4527c33ee662add7bb6c0ff40c6ea962c5ecc0f6
parent eb677ac7cafc1d44165d44c41a26cc197d68484f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 02:34:01 -0400
common: siteRegistry says when the hub's built-ins have loaded — builtinsLoaded (useRegistry, getBuiltinsLoaded) turns true once /hub-sites.json has been answered, found, empty, missing or unreadable, in the same update that adds them; loadBuiltins exported; +8 unit tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 171 insertions(+), 13 deletions(-)
diff --git a/common/components/siteRegistry.test.ts b/common/components/siteRegistry.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ __resetRegistryForTests,
+ getBuiltinsLoaded,
+ getRegisteredSites,
+ loadBuiltins,
+ subscribeRegistry,
+} from "./siteRegistry";
+
+// ─── the built-ins' "loaded" flag ───
+//
+// The hub's /ask counts as ready only once `/hub-sites.json` has been ANSWERED:
+// before that, the list may hold only the archives this browser added, and a
+// question would ground in half the hub. So the flag must turn true for every
+// answer — a list, an empty list, a 404, a network error, bad JSON — and never
+// before the built-ins are in the list (release 11, O1).
+
+const A = {
+ siteId: "a",
+ siteTitle: "Archive A",
+ siteUrl: "https://a.example.com",
+ pwa: true,
+ contract: 1,
+};
+
+type Answer = { status: number; body: string } | "throw";
+
+let restore: (() => void) | null = null;
+
+// A client-side module: it only loads with a `window` to load into.
+function install(answer: Answer): { release: () => void; asked: string[] } {
+ const asked: string[] = [];
+ const realFetch = globalThis.fetch;
+ const hadWindow = "window" in globalThis;
+ (globalThis as { window?: unknown }).window = globalThis;
+ let release!: () => void;
+ const gate = new Promise<void>((r) => (release = r));
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ asked.push(String(input));
+ await gate;
+ if (answer === "throw") throw new TypeError("Failed to fetch");
+ return new Response(answer.body, { status: answer.status });
+ }) as typeof fetch;
+ restore = () => {
+ globalThis.fetch = realFetch;
+ if (!hadWindow) delete (globalThis as { window?: unknown }).window;
+ };
+ return { release, asked };
+}
+
+beforeEach(() => __resetRegistryForTests());
+afterEach(() => {
+ restore?.();
+ restore = null;
+ __resetRegistryForTests();
+});
+
+test("a listed built-in: loaded turns true only once it is in the list, in one update", async () => {
+ const { release, asked } = install({ status: 200, body: JSON.stringify([A]) });
+ const seen: { loaded: boolean; origins: string[] }[] = [];
+ const unsubscribe = subscribeRegistry(() =>
+ seen.push({
+ loaded: getBuiltinsLoaded(),
+ origins: getRegisteredSites().map((s) => s.origin),
+ }),
+ );
+ const pending = loadBuiltins();
+ assert.equal(getBuiltinsLoaded(), false, "not loaded while the file is in flight");
+ release();
+ await pending;
+ unsubscribe();
+ assert.deepEqual(asked, ["/hub-sites.json"]);
+ assert.equal(getBuiltinsLoaded(), true);
+ assert.deepEqual(
+ getRegisteredSites().map((s) => [s.origin, s.kind]),
+ [["https://a.example.com", "builtin"]],
+ );
+ // One notification, and it already carries the built-in: no reader sees
+ // "loaded" over a list that lacks it.
+ assert.deepEqual(seen, [{ loaded: true, origins: ["https://a.example.com"] }]);
+});
+
+for (const [name, answer] of [
+ ["an empty list ([])", { status: 200, body: "[]" }],
+ ["a 404 (no hub-sites.json)", { status: 404, body: "" }],
+ ["a 500", { status: 500, body: "" }],
+ ["a network error", "throw"],
+ ["unreadable JSON", { status: 200, body: "{not json" }],
+ ["JSON that is not a list", { status: 200, body: '{"sites":[]}' }],
+] as [string, Answer][]) {
+ test(`${name}: loaded turns true with no built-ins`, async () => {
+ const { release } = install(answer);
+ let notified = 0;
+ const unsubscribe = subscribeRegistry(() => notified++);
+ const pending = loadBuiltins();
+ assert.equal(getBuiltinsLoaded(), false);
+ release();
+ await pending;
+ unsubscribe();
+ assert.equal(getBuiltinsLoaded(), true);
+ assert.deepEqual(getRegisteredSites(), []);
+ // Subscribers hear it even though the list did not change.
+ assert.equal(notified, 1);
+ });
+}
+
+test("it is asked for once: a second load neither refetches nor re-notifies", async () => {
+ const { release, asked } = install({ status: 200, body: JSON.stringify([A]) });
+ release();
+ await loadBuiltins();
+ let notified = 0;
+ const unsubscribe = subscribeRegistry(() => notified++);
+ await loadBuiltins();
+ unsubscribe();
+ assert.equal(asked.length, 1);
+ assert.equal(notified, 0);
+ assert.equal(getBuiltinsLoaded(), true);
+});
diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts
@@ -67,6 +67,12 @@ const HUB_SITES_URL = "/hub-sites.json";
let sites: RegisteredSite[] = [];
const EMPTY: readonly RegisteredSite[] = Object.freeze([]);
const listeners = new Set<() => void>();
+// `/hub-sites.json` asked for (the one-shot guard) and ANSWERED — found, empty,
+// missing or unreadable. Until it has been answered the list is not the hub's
+// whole list: a surface that must not act on half of it (the /ask chat) waits
+// for `builtinsLoaded`. It is never retried, so a failure settles it too: the
+// hub then lists no built-ins, and says so, rather than loading forever.
+let builtinsRequested = false;
let builtinsLoaded = false;
let hydratedFromStorage = false;
@@ -83,6 +89,11 @@ function getSnapshot(): readonly RegisteredSite[] {
return sites;
}
+// The store outside React (useRegistry is the hook): its current list, and a
+// subscription to every change of it or of `getBuiltinsLoaded()`.
+export const getRegisteredSites = getSnapshot;
+export const subscribeRegistry = subscribe;
+
function getServerSnapshot(): readonly RegisteredSite[] {
// No registry on the server (localStorage/builtins are client-only). A stable
// frozen constant avoids the useSyncExternalStore "getServerSnapshot should be
@@ -90,6 +101,16 @@ function getServerSnapshot(): readonly RegisteredSite[] {
return EMPTY;
}
+// Whether `/hub-sites.json` has been answered (see `builtinsLoaded`). Never on
+// the server: the prerendered page is the "not yet" state.
+export function getBuiltinsLoaded(): boolean {
+ return builtinsLoaded;
+}
+
+function getServerBuiltinsLoaded(): boolean {
+ return false;
+}
+
// Replace the store with a new immutable array (stable ref for the snapshot).
function setSites(next: RegisteredSite[]) {
sites = next;
@@ -154,20 +175,27 @@ function coerceStored(raw: unknown): RegisteredSite | null {
// ---- builtins (trusted pool, emitted by compose-hub) --------------------
-// Load `/hub-sites.json` once. It is absent on non-hub builds and pre-Phase-6
-// hub builds, so a 404/parse failure degrades silently to an empty pool.
-async function loadBuiltins() {
- if (builtinsLoaded || typeof window === "undefined") return;
- builtinsLoaded = true;
- let data: unknown;
+// The entries of `/hub-sites.json`, or none. It is absent on non-hub builds and
+// pre-Phase-6 hub builds, so a 404/parse failure degrades silently to an empty
+// pool.
+async function fetchBuiltins(): Promise<unknown[]> {
try {
const res = await fetch(HUB_SITES_URL);
- if (!res.ok) return;
- data = await res.json();
+ if (!res.ok) return [];
+ const data: unknown = await res.json();
+ return Array.isArray(data) ? data : [];
} catch {
- return;
+ return [];
}
- if (!Array.isArray(data)) return;
+}
+
+// Load `/hub-sites.json` once, then mark the built-ins loaded — whatever the
+// answer was. The built-ins join the list and the flag turns true in ONE
+// update, so no reader ever sees "loaded" over a list that lacks them.
+export async function loadBuiltins(): Promise<void> {
+ if (builtinsRequested || typeof window === "undefined") return;
+ builtinsRequested = true;
+ const data = await fetchBuiltins();
const seen = new Set(sites.map((s) => s.origin));
const builtins: RegisteredSite[] = [];
for (const raw of data) {
@@ -178,10 +206,10 @@ async function loadBuiltins() {
}
if (builtins.length) {
// Builtins sort first (addedAt 0), then externals by add time.
- setSites(
- [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt),
- );
+ sites = [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt);
}
+ builtinsLoaded = true;
+ emit();
}
// A hub-sites.json entry describes a pool site by its public URL. Its origin is
@@ -336,6 +364,10 @@ function removeSite(origin: string) {
export type RegistryApi = {
sites: readonly RegisteredSite[];
+ // True once `/hub-sites.json` has been answered (found, empty, missing or
+ // unreadable): `sites` then holds every built-in this hub has. Until then it
+ // may hold only the archives this browser added.
+ builtinsLoaded: boolean;
addSite: (site: RegisteredSite) => void;
removeSite: (origin: string) => void;
hasOrigin: (origin: string) => boolean;
@@ -347,6 +379,11 @@ export function useRegistry(): RegistryApi {
getSnapshot,
getServerSnapshot,
);
+ const loaded = useSyncExternalStore(
+ subscribe,
+ getBuiltinsLoaded,
+ getServerBuiltinsLoaded,
+ );
useEffect(() => {
// Client-only initialization; both are idempotent (guarded internally).
@@ -356,6 +393,7 @@ export function useRegistry(): RegistryApi {
return {
sites: snapshot,
+ builtinsLoaded: loaded,
addSite,
removeSite,
hasOrigin: (origin) => sites.some((s) => s.origin === origin),
@@ -366,6 +404,7 @@ export function useRegistry(): RegistryApi {
// so a fresh mount re-hydrates. Not used in production paths.
export function __resetRegistryForTests() {
sites = [];
+ builtinsRequested = false;
builtinsLoaded = false;
hydratedFromStorage = false;
emit();