Don't fill cards the reader is flinging past
Profiling the reported stutter on a fixed 400-card grid: 30 cards mounted during one fast scroll, 693ms of blocked main thread -- about 23ms per card. Building the card is 15.6ms of that. The rest is what a mount sets off: a thumbnail request and decode, the height correction its load triggers, a forced layout to measure the title, and an /api/resolve call that runs yt-dlp on the server. Measured separately, a single fast scroll fired twelve of those, peaking at nine a second, for videos the reader never stopped on. None of it is work anyone asked for while the list is moving. So a mount during a fling now only places the card: right size, right position, text and heart in place, so the grid and the scrollbar stay exactly correct. Everything that costs waits 140ms for the scroll to settle, and then runs only for the cards still on screen. Whatever was scrolled past is unmounted having cost almost nothing. The threshold is 1600px/s, well above a deliberate scroll, so reading at a normal pace behaves as it did. The visible trade is that flinging through a long list shows card text over an empty thumbnail box until you slow down. Roughly half the blocked time was browser style, layout, paint and decode that no amount of restructuring removes -- this avoids provoking that work for cards nobody looks at, rather than making it cheaper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -1331,15 +1331,60 @@ App.videos = App.videos || {};
|
|||||||
let builtCards = 0;
|
let builtCards = 0;
|
||||||
let recycledCards = 0;
|
let recycledCards = 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Flinging
|
||||||
|
//
|
||||||
|
// A card that flies past costs far more than it costs to build. Its
|
||||||
|
// thumbnail is a request, a decode and a height correction; its title
|
||||||
|
// is a forced layout; its formats are a yt-dlp extraction on the
|
||||||
|
// server. Every one of those is work for a video the reader has not
|
||||||
|
// looked at, and during a fast scroll there are dozens of them.
|
||||||
|
//
|
||||||
|
// So a mount while flinging only *places* the card -- it takes its
|
||||||
|
// space in the layout, at the right size, with its text. Everything
|
||||||
|
// that costs is deferred to the moment the scroll settles, and only
|
||||||
|
// for the cards still on screen by then. Anything scrolled past in the
|
||||||
|
// meantime is unmounted having cost almost nothing.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
const FLING_PX_PER_MS = 1.6; // ~1600px/s, well past a deliberate scroll
|
||||||
|
const SETTLE_MS = 140;
|
||||||
|
let flinging = false;
|
||||||
|
let lastScrollY = 0;
|
||||||
|
let lastScrollAt = 0;
|
||||||
|
let settleTimer = null;
|
||||||
|
const unfilled = new Set(); // mounted indices still waiting to be filled
|
||||||
|
|
||||||
|
const noteScroll = function() {
|
||||||
|
const now = performance.now();
|
||||||
|
const y = window.scrollY;
|
||||||
|
const dt = now - lastScrollAt;
|
||||||
|
if (dt > 0 && lastScrollAt) {
|
||||||
|
flinging = (Math.abs(y - lastScrollY) / dt) > FLING_PX_PER_MS;
|
||||||
|
}
|
||||||
|
lastScrollY = y;
|
||||||
|
lastScrollAt = now;
|
||||||
|
if (settleTimer) clearTimeout(settleTimer);
|
||||||
|
settleTimer = setTimeout(settle, SETTLE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const settle = function() {
|
||||||
|
settleTimer = null;
|
||||||
|
flinging = false;
|
||||||
|
unfilled.forEach((i) => {
|
||||||
|
const card = mounted.get(i);
|
||||||
|
if (card) fill(i, card, state.loadedVideos[i]);
|
||||||
|
});
|
||||||
|
unfilled.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const acquire = function(v) {
|
const acquire = function(v) {
|
||||||
const card = pool.pop();
|
const card = pool.pop();
|
||||||
if (!card) {
|
if (!card) {
|
||||||
builtCards++;
|
builtCards++;
|
||||||
return App.videos.buildCard(v);
|
return App.videos.buildCard(v, { skipThumbnail: true });
|
||||||
}
|
}
|
||||||
recycledCards++;
|
recycledCards++;
|
||||||
App.videos.bindCard(card, v);
|
App.videos.bindCard(card, v);
|
||||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
|
||||||
return card;
|
return card;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1380,9 +1425,19 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
grid().appendChild(card);
|
grid().appendChild(card);
|
||||||
mounted.set(i, card);
|
mounted.set(i, card);
|
||||||
|
if (flinging) unfilled.add(i);
|
||||||
|
else fill(i, card, v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The part of a mount that costs: the thumbnail, the title measurement
|
||||||
|
// and the format resolve the observer triggers.
|
||||||
|
const fill = function(i, card, v) {
|
||||||
|
if (!card || !v) return;
|
||||||
|
unfilled.delete(i);
|
||||||
|
const img = cardRefs(card).img;
|
||||||
|
App.videos.attachThumbnail(img, v.thumb);
|
||||||
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||||
// its true aspect ratio, clear the shimmer, then correct the height.
|
// its true aspect ratio, clear the shimmer, then correct the height.
|
||||||
const img = card.querySelector('img');
|
|
||||||
if (img) {
|
if (img) {
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
// Release nulls this, so a reveal already queued for a frame
|
// Release nulls this, so a reveal already queued for a frame
|
||||||
@@ -1416,6 +1471,7 @@ App.videos = App.videos || {};
|
|||||||
const card = mounted.get(i);
|
const card = mounted.get(i);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
mounted.delete(i);
|
mounted.delete(i);
|
||||||
|
unfilled.delete(i);
|
||||||
release(card);
|
release(card);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1485,6 +1541,11 @@ App.videos = App.videos || {};
|
|||||||
return tailTop !== Infinity && viewTop + vh >= tailTop;
|
return tailTop !== Infinity && viewTop + vh >= tailTop;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onScroll = function() {
|
||||||
|
noteScroll();
|
||||||
|
scheduleUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleUpdate = function() {
|
const scheduleUpdate = function() {
|
||||||
if (rafPending) return;
|
if (rafPending) return;
|
||||||
rafPending = true;
|
rafPending = true;
|
||||||
@@ -1601,7 +1662,7 @@ App.videos = App.videos || {};
|
|||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
settleWidth = window.innerWidth || 0;
|
settleWidth = window.innerWidth || 0;
|
||||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
// orientationchange fires first (before the viewport metrics change),
|
// orientationchange fires first (before the viewport metrics change),
|
||||||
// which is exactly when the pre-rotation anchor is still valid.
|
// which is exactly when the pre-rotation anchor is still valid.
|
||||||
window.addEventListener('orientationchange', beginSettle);
|
window.addEventListener('orientationchange', beginSettle);
|
||||||
@@ -1620,6 +1681,8 @@ App.videos = App.videos || {};
|
|||||||
// its own cards, and these are exactly the right shape for it.
|
// its own cards, and these are exactly the right shape for it.
|
||||||
mounted.forEach((card, i) => unmount(i));
|
mounted.forEach((card, i) => unmount(i));
|
||||||
mounted.clear();
|
mounted.clear();
|
||||||
|
unfilled.clear();
|
||||||
|
flinging = false;
|
||||||
revealed.clear(); // new result set should animate in again
|
revealed.clear(); // new result set should animate in again
|
||||||
layout.length = 0;
|
layout.length = 0;
|
||||||
colBottoms = [];
|
colBottoms = [];
|
||||||
@@ -1641,7 +1704,8 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stats = function() {
|
const stats = function() {
|
||||||
return { built: builtCards, recycled: recycledCards, pooled: pool.length };
|
return { built: builtCards, recycled: recycledCards, pooled: pool.length,
|
||||||
|
unfilled: unfilled.size, flinging: flinging };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats };
|
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats };
|
||||||
|
|||||||
@@ -170,7 +170,16 @@ def check_cards(c, cards, phase):
|
|||||||
def main():
|
def main():
|
||||||
c = Checks()
|
c = Checks()
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
# Lean launch flags: this runs alongside the app's own server, and a
|
||||||
|
# default Chromium spikes hard enough at startup to get itself killed on
|
||||||
|
# a constrained box.
|
||||||
|
browser = p.chromium.launch(args=[
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--renderer-process-limit=1",
|
||||||
|
"--js-flags=--max-old-space-size=512",
|
||||||
|
])
|
||||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
boot(page)
|
boot(page)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user