[channel] Fix slow/incomplete channel video grid: coalesce loads, bound relays, parity source

Three fixes for "the channel grid loads slowly and not all videos come in even
when they show on other grids":

1. Race condition (primary "won't load" cause). loadUserVideos() bumps a shared
   currentVideoLoadToken and only renders if its token is still current. A burst
   of refresh triggers — e.g. the NIP-71 ingest flushing repeatedly right after
   a channel opens (RELAY STORM in the logs) — supersedes every in-flight load
   before it paints, so the grid stays on "Loading videos…". Add
   requestChannelVideoLoad(): while a load for the same channel is in flight, a
   refresh flags a single trailing rerun instead of starting a competing load;
   navigation still starts immediately. All three call sites routed through it.

2. Slowness from hung relays. The per-relay pool.list() fan-out had no timeout,
   so one relay that never sends EOSE stalled the merged render. Add
   raceListWithTimeout() (exported, pure) bounding each list() to 8s, resolving
   to [] on timeout/rejection.

3. Completeness/parity. The channel grid sourced only nostrClient.allEvents + a
   scoped relay fetch, which can diverge from the shared feed cache the other
   grids render from. getCachedChannelVideoEvents() now also unions the author's
   videos from nostrService.getActiveVideosByAuthors() (same videosMap-backed
   author index), so a video shown on another grid is present here by construction.

Tests: tests/channel-relay-timeout.test.mjs (5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-01 08:47:54 -04:00
1 parent 61e4b25de7
commit a4549f967f
2 files changed
+174 -6

No files matched your search

+109 -6
View File
@@ -76,6 +76,36 @@ import { RELAY_BACKGROUND_CONCURRENCY } from "./nostr/relayConstants.js";
const getApp = () => getApplication();
// A single hung relay (one that never sends EOSE) used to stall the whole
// channel-grid fetch because `pool.list` has no built-in timeout. Bound each
// relay's list() so a slow relay resolves to "no events" instead of holding up
// the merged render — the other relays + the shared cache still fill the grid.
export const CHANNEL_RELAY_LIST_TIMEOUT_MS = 8000;
// Pure, testable core: race a list() call (via a factory) against a timeout.
// Resolves to the list's array on success, or [] on timeout OR rejection — a
// failing/hung relay contributes nothing rather than stalling or throwing (and
// a late rejection after the timeout wins can't surface as unhandled).
export function raceListWithTimeout(listFn, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) {
let timer = null;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve([]), timeoutMs);
});
const listPromise = Promise.resolve()
.then(() => listFn())
.then((result) => (Array.isArray(result) ? result : []))
.catch(() => []);
return Promise.race([listPromise, timeout]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
}
function listRelayWithTimeout(relays, filters, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) {
return raceListWithTimeout(() => nostrClient.pool.list(relays, filters), timeoutMs);
}
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge";
@@ -1310,6 +1340,17 @@ let zapPopoverOpenPromise = null;
let channelMenuPopover = null;
let channelMenuOpen = false;
let currentVideoLoadToken = 0;
// Coalescing state for channel-grid loads. loadUserVideos() bumps
// currentVideoLoadToken and only renders if its token is still current, so a
// burst of refresh triggers (e.g. the NIP-71 ingest flushing repeatedly right
// after open) can supersede every in-flight load before it paints — the grid
// gets stuck on "Loading videos…". requestChannelVideoLoad() collapses those:
// while a load for the same channel is in flight, a refresh flags a single
// trailing rerun instead of starting a competing load. Navigation to a channel
// (isRefresh:false) still starts immediately (and supersedes a stale channel).
let channelVideoLoadInFlight = null;
let channelVideoLoadPubkey = null;
let channelVideoRerunPending = false;
let currentProfileLoadToken = 0;
const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg";
@@ -3821,7 +3862,7 @@ export async function initChannelProfileView() {
syncChannelShareButtonState();
});
const videosPromise = loadUserVideos(hexPub).catch((error) => {
const videosPromise = requestChannelVideoLoad(hexPub).catch((error) => {
userLogger.error("Failed to load channel videos:", error);
});
@@ -4448,7 +4489,26 @@ function getCachedChannelVideoEvents(pubkey) {
}
);
return [...rawMatches, ...processedMatches];
// Parity with the other grids: also pull the author's videos from the shared
// feed cache (nostrService videosMap → author index) — the SAME source the
// main/subscriptions grids render from. The channel grid otherwise sources
// only nostrClient.allEvents + a scoped relay fetch, which can DIVERGE from
// that cache, so a video shown on another grid could be missing here. Appended
// last so it only fills gaps (richer raw/processed copies win the id-dedupe).
let parityMatches = [];
try {
const service = getApp()?.nostrService;
if (service && typeof service.getActiveVideosByAuthors === "function") {
const parity = service.getActiveVideosByAuthors([normalized]);
if (Array.isArray(parity)) {
parityMatches = parity;
}
}
} catch (error) {
// Best-effort; the allEvents scan + scoped relay fetch still populate the grid.
}
return [...rawMatches, ...processedMatches, ...parityMatches];
}
function normalizeRenderableChannelVideo(entry) {
@@ -5315,7 +5375,7 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) {
}
try {
const result = loadUserVideos(currentChannelHex);
const result = requestChannelVideoLoad(currentChannelHex, { isRefresh: true });
return result instanceof Promise ? result : Promise.resolve(result);
} catch (error) {
userLogger.warn(
@@ -5328,6 +5388,49 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) {
}
}
// Entry point for channel-grid loads. Coalesces refresh-driven loads so a
// storm of refreshes can't perpetually cancel each other before rendering.
// isRefresh:true → if a load for the same channel is in flight, flag one
// trailing rerun and reuse the in-flight promise.
// isRefresh:false → navigation; always start (supersedes a stale channel).
function requestChannelVideoLoad(pubkey, { isRefresh = false } = {}) {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (
isRefresh &&
channelVideoLoadInFlight &&
channelVideoLoadPubkey === normalized
) {
channelVideoRerunPending = true;
return channelVideoLoadInFlight;
}
return startChannelVideoLoad(pubkey);
}
function startChannelVideoLoad(pubkey) {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
channelVideoLoadPubkey = normalized;
const promise = Promise.resolve(loadUserVideos(pubkey)).finally(() => {
if (channelVideoLoadInFlight === promise) {
channelVideoLoadInFlight = null;
}
// Fire at most one trailing reload, and only if we're still on this
// channel (navigation elsewhere will have started its own load).
if (channelVideoRerunPending) {
channelVideoRerunPending = false;
const stillHere =
typeof currentChannelHex === "string" &&
currentChannelHex.trim().toLowerCase() === normalized;
if (stillHere) {
startChannelVideoLoad(currentChannelHex);
}
}
});
channelVideoLoadInFlight = promise;
return promise;
}
async function loadUserVideos(pubkey) {
const app = getApp();
const container = document.getElementById("channelVideoList");
@@ -5398,7 +5501,7 @@ async function loadUserVideos(pubkey) {
if (relayList.length === 0) {
try {
const fallbackEvents = await nostrClient.pool.list(
const fallbackEvents = await listRelayWithTimeout(
Array.from(DEFAULT_RELAY_URLS),
filters
);
@@ -5411,7 +5514,7 @@ async function loadUserVideos(pubkey) {
} else {
const settled = await pMapSettled(
relayList,
(url) => nostrClient.pool.list([url], filters),
(url) => listRelayWithTimeout([url], filters),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
);
settled.forEach((result, index) => {
@@ -5493,7 +5596,7 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio
return;
}
loadUserVideos(currentChannelHex).catch((error) => {
requestChannelVideoLoad(currentChannelHex, { isRefresh: true }).catch((error) => {
userLogger.error(
"Failed to refresh channel videos after admin update:",
error
+65
View File
@@ -0,0 +1,65 @@
// Channel-grid relay fetch is bounded (raceListWithTimeout): a hung relay (one
// that never sends EOSE) must resolve to "no events" instead of stalling the
// whole grid render. A failing relay likewise contributes [] rather than
// throwing, and a late rejection after the timeout can't surface as unhandled.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-channel-relay-timeout
// given: "a relay list() that resolves / hangs / rejects / returns non-array"
// when: "raceListWithTimeout wraps it with a short timeout"
// then: "success returns the array; hang/reject/garbage resolve to []"
// observable_outcomes:
// - "a fast success passes the array through unchanged"
// - "a hang resolves to [] within the timeout bound (does not stall)"
// - "a rejection resolves to [] (does not throw)"
// determinism_controls:
// - "explicit short timeoutMs; hang modeled as a never-resolving promise"
// anti_cheat_rationale:
// prevents: ["retry/sleep-based flake masking", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { raceListWithTimeout } from "../js/channelProfile.js";
test("passes a fast successful result through unchanged", async () => {
const events = [{ id: "a" }, { id: "b" }];
const result = await raceListWithTimeout(async () => events, 1000);
assert.deepEqual(result, events);
});
test("a hung relay resolves to [] within the timeout instead of stalling", async () => {
const started = Date.now();
// Never resolves — models a relay that never sends EOSE.
const result = await raceListWithTimeout(() => new Promise(() => {}), 30);
assert.deepEqual(result, []);
assert.ok(Date.now() - started < 500, "must resolve promptly via the timeout");
});
test("a rejecting relay resolves to [] rather than throwing", async () => {
const result = await raceListWithTimeout(async () => {
throw new Error("relay exploded");
}, 1000);
assert.deepEqual(result, []);
});
test("a non-array result is normalized to []", async () => {
const result = await raceListWithTimeout(async () => null, 1000);
assert.deepEqual(result, []);
});
test("a late rejection after the timeout wins does not throw (no unhandled)", async () => {
// The list rejects AFTER the timeout has already resolved to []. The helper
// must swallow it so it can't surface as an unhandled rejection.
const result = await raceListWithTimeout(
() => new Promise((_, reject) => setTimeout(() => reject(new Error("late")), 40)),
10,
);
assert.deepEqual(result, []);
// Give the late rejection a tick to (not) blow up.
await new Promise((resolve) => setTimeout(resolve, 60));
});