Recycle grid cards instead of rebuilding them

Scrolling the grid did nothing but destroy cards and build near-identical
ones back: a template string parsed as innerHTML, ten querySelectors, and
a listener per interactive element, every time a card entered the window.
The virtualizer now keeps a pool and rebinds a card it already has --
18us against 136us to build one, and 74-83% of mounts are served from it.

Two things had to change first. Nothing on a card may close over the
video it is showing, because the card outlives the video, so every
interaction moved to one delegated listener per event type on the grid.
And every card now has the same shape whatever it shows: the optional
parts are always present and hidden when unused, so any pooled card fits
any video. That needed a global [hidden] rule, since .live-badge and
.video-tags carry their own display.

The rest is the release path, which is where this design lives or dies.
A thumbnail carries a generation, so a race or a proxy fallback settling
after the card moved on cannot paint over the video now showing. The
player stamps the card it was opened from, so a recycled element stops
answering for it. The reveal handler, the entrance-animation listener and
the hover preview are all taken back off. Anything missed here surfaces
as one video's title, thumbnail or heart on another video's card, which
is what the smoke suite scrolls back and forth to catch.

Two incidental fixes found while measuring: bindCard no longer writes a
data-tag per tag button (dataset is a proxy, and that alone cost more
than the rest of a rebind put together -- the handler reads the label off
the button), and favorites.has no longer parses a URL for every card that
isn't a favorite by key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-09 09:47:43 +00:00
parent b4031b5d0e
commit 49992c1db0
6 changed files with 470 additions and 155 deletions

View File

@@ -23,6 +23,7 @@ App.player = App.player || {};
historyPushed: false,
idleTimer: null,
originEl: null,
originToken: null, // stamp proving originEl is still the card we opened
hudHovered: false, // mouse resting on the controls (desktop)
activeUrl: '', // media URL actually playing, for the format menu's tick
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
@@ -59,6 +60,24 @@ App.player = App.player || {};
}
}
// The card the player was opened from is owned by the grid, which pools and
// reuses its cards (see resetCard in videos.js). By the time the player lets
// go, that element may be showing a different video -- so it is stamped at
// open, and every later touch checks the stamp still matches. A recycled
// card has had it wiped, and simply stops answering.
let originSeq = 0;
const claimOrigin = function(el) {
if (!el) return null;
const token = String(++originSeq);
el.dataset.playerToken = token;
return token;
};
const withOrigin = function(el, token, fn) {
if (el && el.dataset.playerToken === token) fn(el);
};
const addCleanup = (fn) => cp.cleanups.push(fn);
const runCleanups = () => {
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
@@ -695,9 +714,10 @@ App.player = App.player || {};
// the wrong card loaded or (via the token guard below) never clear
// this card's spinner at all.
const originEl = (opts && opts.originEl) || null;
const originToken = originEl ? originEl.dataset.playerToken : null;
const sources = resolveSources(videoData);
const clearLoading = () => {
if (originEl) originEl.classList.remove('is-loading');
withOrigin(originEl, originToken, (el) => el.classList.remove('is-loading'));
};
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
@@ -921,11 +941,12 @@ App.player = App.player || {};
cp.attemptToken++;
cancelInFlight();
clearIdleTimer();
if (cp.originEl) cp.originEl.classList.remove('is-loading');
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
}
runCleanups();
cp.originEl = opts && opts.originEl ? opts.originEl : null;
cp.originToken = claimOrigin(cp.originEl);
if (cp.originEl) cp.originEl.classList.add('is-loading');
cp.container = buildContainer();
@@ -1031,10 +1052,12 @@ App.player = App.player || {};
cp.historyPushed = false;
}
if (cp.originEl) {
cp.originEl.classList.remove('is-loading');
cp.originEl = null;
}
withOrigin(cp.originEl, cp.originToken, (el) => {
el.classList.remove('is-loading');
delete el.dataset.playerToken;
});
cp.originEl = null;
cp.originToken = null;
cp.data = null;
cp.source = null; // voids a still-pending format resolve for this open
cp.formatOverride = null;