[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