diff --git a/frontend/css/style.css b/frontend/css/style.css index 390df84..d86937c 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1047,6 +1047,12 @@ body.favorites-view-open .favorites-empty { precomputed (top,left). */ } +/* Each card's layout and paint stay its own business, so inserting one during + a scroll cannot make the browser reconsider the rest of the grid. */ +.video-card { + contain: layout paint; +} + .video-card:hover { transform: translateY(-6px); box-shadow: 0 16px 28px var(--shadow); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 8bf26c9..57309c9 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -1179,7 +1179,13 @@ App.videos = App.videos || {}; let rafPending = false; let lastAnchor = null; // reader's place, refreshed on every scroll pass let heldAnchor = null; // anchor frozen while a resize settles - const OVERSCAN = 1.2; // viewports of cards kept mounted off-screen + // Viewports of cards kept mounted off-screen. Narrowing this during a + // fling looks like an obvious saving and measures as the opposite: a + // tight window means cards leave and re-enter it as the viewport moves, + // and the remounting costs far more than the painting it avoids. Tried + // at 0.25 -- mounts went from 88 to 155 and blocked time from 3.6s to + // 4.3s. It stays generous on purpose. + const OVERSCAN = 1.2; const grid = () => document.getElementById('video-grid'); @@ -1290,6 +1296,10 @@ App.videos = App.videos || {}; colBottoms[col] = top + h + gap; } setContainerHeight(); + // A page has just landed -- its JSON and thumbnails were prefetched, + // so build its cards too while there is time, rather than when the + // reader arrives at them. + prepareAhead(); }; // Replaces item i's estimated height with its real (post-image-load) @@ -1330,6 +1340,7 @@ App.videos = App.videos || {}; const POOL_MAX = 60; let builtCards = 0; let recycledCards = 0; + let preparedCards = 0; // --------------------------------------------------------------- // Flinging @@ -1367,17 +1378,141 @@ App.videos = App.videos || {}; settleTimer = setTimeout(settle, SETTLE_MS); }; + // DOM work cannot leave the main thread -- no worker can touch it -- so + // "smooth" is not a matter of doing it elsewhere but of never holding + // the thread long enough to miss a frame. Both queues below are + // therefore drained against a budget: a few cards per frame, the rest + // carried to the next one. Total work is unchanged; what changes is + // that it stops arriving in one lump. + const FRAME_BUDGET_MS = 4; + const settle = function() { settleTimer = null; flinging = false; - unfilled.forEach((i) => { + drainFills(); + warmPool(); + prepareAhead(); + }; + + // Filling every deferred card at once -- which is what settling used to + // do -- just moves the stall from during the fling to the end of it. + const drainFills = function() { + const started = performance.now(); + const queued = Array.from(unfilled); + for (let k = 0; k < queued.length; k++) { + if (performance.now() - started >= FRAME_BUDGET_MS) break; + const i = queued[k]; const card = mounted.get(i); if (card) fill(i, card, state.loadedVideos[i]); + else unfilled.delete(i); + } + if (unfilled.size) requestAnimationFrame(drainFills); + }; + + // Cards entering the window, mounted a few per frame. A fast scroll can + // bring a dozen or more into range at once, and mounting them in one + // callback is a frame missed no matter how cheap each card is. + let mountQueue = []; + + const drainMounts = function() { + const started = performance.now(); + // One insertion for the whole frame's worth of cards rather than one + // per card: the browser gets a single subtree to take in. + const batch = document.createDocumentFragment(); + const placed = []; + while (mountQueue.length && performance.now() - started < FRAME_BUDGET_MS) { + const i = mountQueue.shift(); + const card = mount(i, batch); + if (card) placed.push([i, card]); + } + if (placed.length) grid().appendChild(batch); + // Filling reads layout, so it happens after the batch is in. + placed.forEach(([i, card]) => { + const v = state.loadedVideos[i]; + if (flinging) unfilled.add(i); + else fill(i, card, v); }); - unfilled.clear(); + if (mountQueue.length) requestAnimationFrame(drainMounts); + }; + + // Cards built ahead of being needed, while nothing else is happening, + // so a mount during a scroll is always the cheap rebind and never the + // expensive construction. The page already prefetches the next page's + // JSON and thumbnails (see prefetchNextBatch); this is the same idea + // applied to the DOM those items will need. + const POOL_WARM_TARGET = 24; + + const warmPool = function() { + if (pool.length >= POOL_WARM_TARGET) return; + idle(() => { + const deadline = performance.now() + 3; + while (pool.length < POOL_WARM_TARGET && performance.now() < deadline) { + pool.push(createCardShell()); + } + if (pool.length < POOL_WARM_TARGET) warmPool(); + }); + }; + + // Cards for videos just past the window, built and bound to their video + // ahead of time. This is as close to "prepare the next page off-thread" + // as a browser allows: the DOM itself can only ever be built on the main + // thread, so the saving comes from doing it while nothing else is + // happening rather than during the scroll that needs it. + const PREPARE_AHEAD = 12; + const prepared = new Map(); // video id -> card, ready to place + + let preparing = false; + + const prepareAhead = function() { + if (preparing) return; // one idle pass at a time, not one per call + preparing = true; + idle(() => { + preparing = false; + const videos = state.loadedVideos; + if (!videos.length) return; + // Start from the last mounted index: the cards after it are the + // ones the reader is heading towards. + let highest = -1; + mounted.forEach((card, i) => { if (i > highest) highest = i; }); + const deadline = performance.now() + 3; + for (let i = highest + 1; i < videos.length && i <= highest + PREPARE_AHEAD; i++) { + if (performance.now() >= deadline) break; + const v = videos[i]; + if (!v || mounted.has(i) || prepared.has(String(v.id))) continue; + const card = pool.pop() || createCardShell(); + App.videos.bindCard(card, v); + prepared.set(String(v.id), card); + } + }); + }; + + // Hands back a card already bound to this video, if one was prepared. + const takePrepared = function(v) { + const key = String(v.id); + const card = prepared.get(key); + if (!card) return null; + prepared.delete(key); + // Favouriting may have happened since it was prepared, and the heart + // is the one part of a card that changes without the video changing. + App.favorites.setButtonState(cardRefs(card).favorite, + !!App.favorites.getKey(v) && App.favorites.has(v)); + return card; + }; + + const dropPrepared = function() { + prepared.forEach((card) => { + App.videos.resetCard(card); + if (pool.length < POOL_MAX) pool.push(card); + }); + prepared.clear(); }; const acquire = function(v) { + const ready = takePrepared(v); + if (ready) { + preparedCards++; + return ready; + } const card = pool.pop(); if (!card) { builtCards++; @@ -1404,11 +1539,11 @@ App.videos = App.videos || {}; if (pool.length < POOL_MAX) pool.push(card); }; - const mount = function(i) { - if (mounted.has(i)) return; + const mount = function(i, batch) { + if (mounted.has(i)) return null; const v = state.loadedVideos[i]; const l = layout[i]; - if (!v || !l) return; + if (!v || !l) return null; const card = acquire(v); place(card, l); // Entrance animation only the first time an index appears, so cards @@ -1423,10 +1558,12 @@ App.videos = App.videos || {}; card._revealAnim = onRevealEnd; card.addEventListener('animationend', onRevealEnd, { once: true }); } - grid().appendChild(card); + (batch || grid()).appendChild(card); mounted.set(i, card); + if (batch) return card; // the caller fills, once the batch is in if (flinging) unfilled.add(i); else fill(i, card, v); + return card; }; // The part of a mount that costs: the thumbnail, the title measurement @@ -1509,7 +1646,10 @@ App.videos = App.videos || {}; anchorIndex = i; } } - for (let k = 0; k < entering.length; k++) mount(entering[k]); + // Unmounting already happened above, so the pool is stocked before + // anything asks it for a card. + mountQueue = entering; + drainMounts(); // Frozen while a resize settles: the browser moves the scroll // position itself during a rotation, and tracking that would replace // the reader's real place with wherever the browser landed. @@ -1662,6 +1802,7 @@ App.videos = App.videos || {}; if (initialized) return; initialized = true; settleWidth = window.innerWidth || 0; + warmPool(); window.addEventListener('scroll', onScroll, { passive: true }); // orientationchange fires first (before the viewport metrics change), // which is exactly when the pre-rotation anchor is still valid. @@ -1682,6 +1823,8 @@ App.videos = App.videos || {}; mounted.forEach((card, i) => unmount(i)); mounted.clear(); unfilled.clear(); + dropPrepared(); + mountQueue = []; flinging = false; revealed.clear(); // new result set should animate in again layout.length = 0; @@ -1704,7 +1847,8 @@ App.videos = App.videos || {}; }; const stats = function() { - return { built: builtCards, recycled: recycledCards, pooled: pool.length, + return { built: builtCards, recycled: recycledCards, prepared: preparedCards, + pooled: pool.length, readied: prepared.size, unfilled: unfilled.size, flinging: flinging }; }; diff --git a/tests/smoke_grid.py b/tests/smoke_grid.py index fbf76c8..4efeb67 100644 --- a/tests/smoke_grid.py +++ b/tests/smoke_grid.py @@ -229,13 +229,19 @@ def main(): () => (App.virtualGrid.stats && App.virtualGrid.stats()) || null """) if stats: - total = (stats.get("built", 0) + stats.get("recycled", 0)) or 1 - rate = 100 * stats.get("recycled", 0) // total - print(f"\npool: {stats.get('recycled', 0)} recycled / {total} mounts " - f"({rate}% hit rate), {stats.get('pooled', 0)} idle in pool") - # A hit rate near zero means cards are still being built per mount, - # so none of the checks above actually exercised a recycled card. - c.ok("cards are actually being recycled", rate >= 50, f"{rate}% hit rate") + built = stats.get("built", 0) + recycled = stats.get("recycled", 0) + readymade = stats.get("prepared", 0) + total = (built + recycled + readymade) or 1 + reused = 100 * (recycled + readymade) // total + print(f"\nmounts: {total} -- {built} built, {recycled} recycled, " + f"{readymade} prepared ahead; {stats.get('pooled', 0)} idle in pool, " + f"{stats.get('readied', 0)} still readied") + # If almost everything is still built per mount, none of the checks + # above actually exercised a reused card. + c.ok("cards are reused rather than rebuilt", reused >= 50, f"{reused}% reused") + c.ok("some cards were prepared before they were needed", readymade > 0, + "prepare-ahead never served a mount") browser.close()