From 122d806ad20f7ca613e2c68037ab843f0cfa23d6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:31:38 -0400 Subject: [PATCH] perf(feed): don't block fresh load on remote admin-list fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadVideos awaited ensureAccessControlReady -> waitForReady -> _performRefresh -> loadAdminState, which awaits a remote admin-list fetch. On a cold load that waits for relays to connect (~5s) plus the fetch, stalling the feed ~10s before any video renders. The cached admin state IS hydrated synchronously at construction, but with markLoaded:false, so waitForReady blocked on a full remote refresh even when a usable cache existed ("refresh fixes it"). ensureAccessControlReady now blocks only on a first-ever cold load WITH whitelist mode (where showing non-whitelisted content before the whitelist arrives would be wrong). In every other case — admin state hydrated from the localStorage cache, already loaded, or blacklist-only mode — it kicks the refresh off in the background (ensureReady) and renders immediately; the reactive onWhitelist/onBlacklistChange handlers re-filter the grid once fresh lists land. Adds accessControl.isHydrated() and a branching test (nostr-service-access-control-ready.test.mjs). Co-Authored-By: Claude Opus 4.8 --- js/accessControl.js | 8 +++ js/services/nostrService.js | 18 ++--- ...ostr-service-access-control-ready.test.mjs | 70 +++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 tests/nostr-service-access-control-ready.test.mjs diff --git a/js/accessControl.js b/js/accessControl.js index 901e94e1..67cc834b 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -460,6 +460,14 @@ class AccessControl { return this.whitelistEnabled; } + // True once we have usable lists to filter with — either a completed refresh + // or admin state hydrated synchronously from the localStorage cache at + // construction. Callers use this to avoid blocking the feed on a remote + // refresh when cached lists already allow accurate canAccess() checks. + isHydrated() { + return this.hasLoaded === true || this._hydratedFromCache === true; + } + isSuperAdmin(npub) { const normalized = normalizeNpub(npub); return normalized ? normalized === ADMIN_SUPER_NPUB : false; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ff1d9dc5..639f2713 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1556,17 +1556,17 @@ export class NostrService { } async ensureAccessControlReady() { - if (!this.accessControl || typeof this.accessControl.waitForReady !== "function") { - return; - } - + const ac = this.accessControl; + if (!ac) return; + // Block on the remote admin fetch only on a first-ever cold whitelist load; + // else the hydrated cache is accurate, so refresh in bg (handlers re-filter). + const hydrated = ac.isHydrated?.() ?? ac.hasLoaded === true; + const block = !hydrated && (ac.whitelistMode?.() ?? false); + const ready = block ? ac.waitForReady : ac.ensureReady || ac.waitForReady; try { - await this.accessControl.waitForReady(); + if (typeof ready === "function") await ready.call(ac); } catch (error) { - userLogger.warn( - "[nostrService] Failed to ensure access control lists are ready:", - error - ); + userLogger.warn("[nostrService] access control ready failed:", error); } } diff --git a/tests/nostr-service-access-control-ready.test.mjs b/tests/nostr-service-access-control-ready.test.mjs new file mode 100644 index 00000000..217e690d --- /dev/null +++ b/tests/nostr-service-access-control-ready.test.mjs @@ -0,0 +1,70 @@ +// Verifies ensureAccessControlReady no longer blocks the feed on the remote +// admin-list fetch except on a first-ever cold load in whitelist mode. +// +// Scenario (SCN-access-control-non-blocking-feed): +// - admin state hydrated (cache or loaded) => background refresh (ensureReady) +// - cold + whitelist mode => block (waitForReady) +// - cold + blacklist-only mode => background refresh (ensureReady) + +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./test-helpers/setup-localstorage.mjs"; +import moderationService from "../js/services/moderationService.js"; +import { NostrService } from "../js/services/nostrService.js"; + +// Stub background timers the service may schedule. +if (moderationService) { + moderationService.scheduleTrustedMuteSubscriptionRefresh = () => {}; + moderationService.refreshTrustedMuteSubscriptions = async () => {}; +} +if (typeof globalThis.window === "undefined") globalThis.window = {}; + +function makeAccessControl({ hydrated, whitelist }) { + const calls = { ensureReady: 0, waitForReady: 0 }; + return { + calls, + isHydrated: () => hydrated, + whitelistMode: () => whitelist, + ensureReady: async () => { + calls.ensureReady += 1; + }, + waitForReady: async () => { + calls.waitForReady += 1; + }, + }; +} + +test("hydrated => background refresh, never blocks on waitForReady", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: true, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("cold + whitelist mode => blocks on waitForReady (correctness)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.waitForReady, 1); + assert.equal(ac.calls.ensureReady, 0); +}); + +test("cold + blacklist-only mode => background refresh (no block)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: false }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("missing access control is a no-op", async () => { + const service = new NostrService(); + service.accessControl = null; + await service.ensureAccessControlReady(); // must not throw + assert.ok(true); +});