aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 14:48:13 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 14:48:13 +0200
commitb83803a91753b38435eb4d3be2e683daae9b4de9 (patch)
tree7581649be4bf99f2998330fd33ac52e234a83af4 /packages/meshbay-hub/src/meshbay_hub/static/search-page.js
parentcfc91e0a424163869c64d30e55d55a53f18a3dbf (diff)
downloadmeshbay-b83803a91753b38435eb4d3be2e683daae9b4de9.tar.gz
fix(hub): stop Search-view video posters flickering to spinners
The cross-group "Search files" view caps its WebRTC connection pool at MAX_POOL_SIZE but pre-connected every indexed group, so any account with more groups than the cap thrashed the pool. An evicted transport was never removed from SearchPage's own groupConns map, so every tile of that group kept a `_tRef` pointing at a closed transport; MediaThumb and useMediaMeta bail on a disconnected transport with no retry, so posters rendered for a moment then fell back to a loading spinner for good. Every connect also fired the module-wide bumpMediaMetaGeneration(), clearing every mounted tile's metadata across all groups and flickering the whole grid through the warm-up walk. A one-group account (grenet) never hit it; a many-group account (cbesson) always did. - ConnectionPool takes an onEvict callback; SearchPage prunes groupConns and rebuilds the entry lists when a connection is evicted or closed. - MAX_POOL_SIZE 3 -> 12; the pre-connect walk is capped to it. Groups past the cap connect lazily when a tile scrolls into view (onNeedConn). - connectGroup() no longer fires the module-wide meta/thumb generation bumps on a normal connect (kept for operator TMDB override/rematch). Per-group gen counter + concurrent-caller guard: a group's tiles refetch once per (re)connect, not once per mounting tile. - MediaThumb/useMediaMeta take an optional reloadKey; each group's `_connGen` is threaded through so a tile refetches when its group reconnects instead of staying stuck on a spinner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4Yj8EuXkjjYxbeS5kPd3U
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/search-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js83
1 files changed, 69 insertions, 14 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index f144ef0..ec92e76 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -8,14 +8,22 @@ import {
HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
} from './hub-client.js';
import { FilesPanel, FilePreview } from './files-app.js';
-import { VideoApp, bumpMediaMetaGeneration, bumpThumbGeneration } from './video-app.js';
-import { MusicApp, bumpMusicMetaGeneration } from './music-app.js';
+import { VideoApp } from './video-app.js';
+import { MusicApp } from './music-app.js';
import { PhotosApp } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
const BATCH_SIZE = 3;
-const MAX_POOL_SIZE = 3;
+// One WebRTC peer connection per group the search view touches. The cap bounds
+// how many a busy account (many groups on the hub) keeps open at once; groups
+// past it connect lazily when a tile of theirs scrolls into view (video-app.js's
+// LazyTile → onNeedConn). Was 3, which meant any account with more than three
+// groups thrashed the pool: an evicted transport left a stale `_tRef` on every
+// tile of that group, and MediaThumb / useMediaMeta gave up on it with no
+// retry — so posters rendered for a moment, then fell back to a spinner for
+// good. grenet (one shared group) never hit it; cbesson (many) always did.
+const MAX_POOL_SIZE = 12;
const DEBOUNCE_MS = 200;
const SEARCH_TIMEOUT = 10000;
const SEARCH_VIDEO_ROOT = '__search__';
@@ -25,10 +33,14 @@ const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
// -- Connection pool ----------------------------------------------------------
class ConnectionPool {
- constructor(hubBase) {
+ constructor(hubBase, onEvict) {
this._hubBase = hubBase;
this._connections = new Map();
this._connecting = new Map();
+ // Called with a groupId whenever this pool closes that group's connection
+ // (eviction or closeAll). SearchPage uses it to drop its own record so it
+ // never hands a tile a `_tRef` pointing at a transport just closed here.
+ this._onEvict = onEvict || (() => {});
}
async connect(groupId, token, bundleKey, username, userId) {
@@ -94,12 +106,14 @@ class ConnectionPool {
const conn = this._connections.get(oldestId);
try { conn.transport.close(); } catch {}
this._connections.delete(oldestId);
+ this._onEvict(oldestId);
}
}
closeAll() {
- for (const [, conn] of this._connections) {
+ for (const [id, conn] of this._connections) {
try { conn.transport.close(); } catch {}
+ this._onEvict(id);
}
this._connections.clear();
for (const [, p] of this._connecting) {
@@ -202,13 +216,28 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const modalTransportRef = useRef(null);
const modalGekRef = useRef(null);
const groupConns = useRef(new Map());
+ // groupId -> how many times its connection has been (re)established. Threaded
+ // into each entry as `_connGen` and used by video-app.js's tiles as a refetch
+ // key, so a group's posters/thumbnails recover the moment it reconnects
+ // (after a pool eviction) instead of staying stuck on a spinner.
+ const connGenRef = useRef(new Map());
+ const mountedRef = useRef(true);
const [connectionGen, setConnectionGen] = useState(0);
const debounceRef = useRef(null);
const [debouncedQuery, setDebouncedQuery] = useState('');
useEffect(() => {
- poolRef.current = new ConnectionPool(HUB);
- return () => { if (poolRef.current) poolRef.current.closeAll(); };
+ mountedRef.current = true;
+ poolRef.current = new ConnectionPool(HUB, (evictedId) => {
+ groupConns.current.delete(evictedId);
+ // Rebuild the entry lists so tiles of the evicted group fall back to a
+ // null `_tRef` (and pick a live one up again once reconnected).
+ if (mountedRef.current) setConnectionGen((g) => g + 1);
+ });
+ return () => {
+ mountedRef.current = false;
+ if (poolRef.current) poolRef.current.closeAll();
+ };
}, []);
useEffect(() => {
@@ -247,29 +276,50 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
if (!poolRef.current) throw new Error('no pool');
const bundleKey = session.bundleKey || await _loadBundleKey();
const conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId);
+
+ // A concurrent caller for the same group (several tiles mounting at once)
+ // may already have recorded this exact transport while we awaited. Only
+ // treat it as new — bump the per-group generation, force a rebuild — when
+ // it genuinely is, so a group's tiles refetch once per (re)connect rather
+ // than once per mounting tile.
+ const existing = groupConns.current.get(groupId);
+ if (existing && existing.transport === conn.transport) {
+ existing.lastUsed = Date.now();
+ return existing;
+ }
+
+ const gen = (connGenRef.current.get(groupId) || 0) + 1;
+ connGenRef.current.set(groupId, gen);
const entry = {
transport: conn.transport,
gek: conn.gek,
+ gen,
tRef: { current: conn.transport },
gRef: { current: conn.gek },
};
groupConns.current.set(groupId, entry);
+ // No bumpMediaMetaGeneration() / bumpThumbGeneration() here: a fresh
+ // transport does not invalidate metadata another group already resolved,
+ // and firing the module-wide reset on every connect is what made the whole
+ // grid flicker through the pre-connect walk. Recovery for *this* group's
+ // tiles comes from `_connGen` (threaded into its entries) instead; the
+ // module-wide bumps stay for an operator's TMDB override/rematch only.
setConnectionGen((g) => g + 1);
- bumpMediaMetaGeneration();
- bumpThumbGeneration();
- bumpMusicMetaGeneration();
return entry;
}, [token, username, userId]);
- // Pre-connect to all groups as soon as indexing finishes so thumbnails
- // start loading before the user switches views. The pool evicts old
- // connections but _thumbBlobCache keeps fetched thumbnails across evictions.
+ // Warm up connections as soon as indexing finishes so thumbnails start
+ // loading before the user switches views — but only up to the pool's
+ // capacity. Warming every group would just evict the earlier ones before
+ // the user ever gets there; groups past the cap connect lazily when a tile
+ // of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps
+ // fetched thumbnails across evictions.
useEffect(() => {
if (fetching || indexedGroups.size === 0) return;
let cancelled = false;
(async () => {
- const groupIds = [...indexedGroups.keys()];
+ const groupIds = [...indexedGroups.keys()].slice(0, MAX_POOL_SIZE);
for (let i = 0; i < groupIds.length; i += BATCH_SIZE) {
if (cancelled) break;
const batch = groupIds.slice(i, i + BATCH_SIZE);
@@ -325,6 +375,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -356,6 +407,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -381,6 +433,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -407,6 +460,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -570,6 +624,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
videoRoot=${SEARCH_VIDEO_ROOT}
tmdbConfig=${{ enabled: true }}
isNodeAdmin=${false}
+ onNeedConn=${connectGroup}
hideFilter=${true} />
`}