channel: union live fetch with known events so the grid never shrinks

loadUserVideos rendered only the live relay fetch, and the render REPLACES the
grid container. So when a relay holding a creator's video transiently failed or
EOSE'd late, that video disappeared from the wall even though it was already
known in memory/cache — the "channel doesn't pull in all of a creator's videos"
bug (#20, symptom 1).

Union the live events with the already-known author events
(mergeChannelVideoSources, live wins on id collision) before building the
renderable list, so the rendered set is a superset and can't shrink on a
transient relay gap. Existing dedupe-by-root + access/delete filtering still
decide precedence downstream, so newer versions and deletions still win.

Helper + tests live in channelProfileVideos.js (channelProfile.js is at its
size cap); the call site stays net-zero. Streaming/first-relay-wins render is
still the separate 17d UX follow-up.

Regression: tests/channel-video-merge.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 08:19:37 -04:00
1 parent 2c62b959fd
commit f05ba5edf9
4 files changed
+90 -3

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
// js/channelProfile.js
import { nostrClient } from "./nostrClientFacade.js";
import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos } from "./channelProfileVideos.js";
import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js";
import { dedupeVideos } from "./utils/videoDeduper.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { subscriptions } from "./subscriptions.js";
@@ -5440,7 +5440,7 @@ async function loadUserVideos(pubkey) {
return;
}
const videos = buildRenderableChannelVideos({ events, app });
const videos = buildRenderableChannelVideos({ events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app });
saveCachedChannelVideos(pubkey, videos);
const rendered = await renderChannelVideosFromList({
videos,
+27
View File
@@ -28,6 +28,33 @@ export function convertChannelEvent(entry) {
return sharedConvertEventToVideo(entry);
}
// Reconcile a fresh live fetch with already-known events (the in-memory video
// store + persisted cache) so a transiently-failing relay can't SHRINK the grid.
// The channel render replaces the container with the live set, so rendering only
// what this load fetched drops any video whose sole relay timed out — the "some
// of a creator's videos are missing" bug. Union the two by event id (live wins on
// collision since it's the freshest copy); the caller's dedupe-by-root + access
// checks still handle versioning, deletes, and precedence downstream.
export function mergeChannelVideoSources(liveEvents, knownEvents) {
const byId = new Map();
const extras = [];
const add = (list) => {
if (!Array.isArray(list)) return;
for (const entry of list) {
if (!entry || typeof entry !== "object") continue;
const id = typeof entry.id === "string" ? entry.id : "";
if (!id) {
extras.push(entry);
continue;
}
if (!byId.has(id)) byId.set(id, entry);
}
};
add(liveEvents); // live first → wins on id collision
add(knownEvents);
return [...byId.values(), ...extras];
}
// ---- Per-channel persisted cache (instant cold-load paint) -------------------
// Mirrors the main feed's optimistic cache: on a cold hard-refresh of a profile,
// render the last-seen videos from localStorage BEFORE relays connect, then the
+52
View File
@@ -0,0 +1,52 @@
// Regression (#20): the channel grid render replaces the container with the live
// fetch, so a transiently-failing relay produced a smaller set and previously-
// known videos vanished ("some of a creator's videos are missing"). The live
// fetch must be unioned with already-known events so the grid never shrinks.
import assert from "node:assert/strict";
import test from "node:test";
import { mergeChannelVideoSources } from "../js/channelProfileVideos.js";
const ev = (id, extra = {}) => ({ id, kind: 30078, ...extra });
test("union is a superset of both live and known events", () => {
const live = [ev("a"), ev("b")];
const known = [ev("b"), ev("c"), ev("d")];
const merged = mergeChannelVideoSources(live, known);
const ids = merged.map((e) => e.id).sort();
assert.deepEqual(ids, ["a", "b", "c", "d"], "all unique ids from both sources");
});
test("a known video missing from the live fetch still appears (the bug)", () => {
// The live fetch this load only returned one video; the other is known in
// memory (e.g. from a prior load / the main feed). It must not disappear.
const live = [ev("only-live")];
const known = [ev("only-live"), ev("dropped-by-slow-relay")];
const merged = mergeChannelVideoSources(live, known);
const ids = merged.map((e) => e.id).sort();
assert.deepEqual(ids, ["dropped-by-slow-relay", "only-live"]);
});
test("live wins on id collision (freshest copy)", () => {
const live = [ev("x", { created_at: 200, source: "live" })];
const known = [ev("x", { created_at: 100, source: "cache" })];
const merged = mergeChannelVideoSources(live, known);
assert.equal(merged.length, 1);
assert.equal(merged[0].source, "live", "live copy must win the dedupe");
});
test("preserves id-less entries and tolerates non-array / junk input", () => {
const live = [ev("a"), { kind: 30078 /* no id */ }, null, "junk"];
const merged = mergeChannelVideoSources(live, undefined);
const withId = merged.filter((e) => e && e.id === "a");
const withoutId = merged.filter((e) => e && typeof e === "object" && !e.id);
assert.equal(withId.length, 1);
assert.equal(withoutId.length, 1, "id-less event kept, not dropped");
assert.deepEqual(mergeChannelVideoSources(null, null), []);
});
+9 -1
View File
@@ -267,7 +267,15 @@ un-hides when WebTorrent flips green). The real gaps:
### 20. Feed / grid loading reliability — some videos missing (BUG)
Two related intermittent symptoms; likely the same root cause (a one-shot,
all-relays-settle fetch that drops slow-relay results — see 17d).
- [ ] **Profile / Channel page sometimes doesn't pull in all of a creator's videos.**
- [x] **Profile / Channel page sometimes doesn't pull in all of a creator's
videos.** Root cause: `loadUserVideos` rendered ONLY the live fetch, which
REPLACES the grid — so when a relay holding a video transiently failed this
load, that video vanished even though it was known in memory/cache. Fixed by
unioning the live events with already-known events
(`mergeChannelVideoSources`, live-wins-on-id) before render, so the grid is a
superset and never shrinks; existing dedupe-by-root + access checks still
handle versioning/deletes. Regression: `tests/channel-video-merge.test.mjs`.
(Streaming/first-relay-wins UX is still the 17d follow-up.)
- [ ] **Paginating between video-grid pages sometimes doesn't pull in all videos.**
- [ ] Audit the channel/search/grid fetch for: relays that EOSE late being dropped,
pagination cursor gaps, dedup discarding valid items, and cache-vs-live