From 6e5ab68a94e460e5ea4608722b14e6cce18f85c3 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 9 Sep 2026 19:04:10 +0000 Subject: [PATCH] Prepare cards ahead of the scroll, and insert them in batches DOM work cannot leave the main thread -- a worker has no document, and nodes are not transferable -- so a card can never be compiled elsewhere. It can be compiled *earlier*. The page already prefetches the next page's JSON and warms its thumbnails; this does the same for the cards those items will need, building and binding them while the browser is idle and handing them over ready when the reader arrives. Alongside that, three things that keep a frame from being held too long, which is what smoothness actually reduces to when the work has nowhere else to go: Mounting and filling are drained against a 4ms budget rather than all at once. Filling in one pass was a regression I introduced with the fling deferral: it moved the stall from during the fling to the end of it. A frame's mounts go into a DocumentFragment and enter the document in one insertion, with filling afterwards so nothing reads layout mid-insert. The pool is topped up with card shells during idle, so a mount during a scroll is a rebind and not a construction: cards built mid-scroll fell from 24 to 5 across profiling runs. Also reverted, with its numbers kept in a comment: narrowing the overscan during a fling. It reads like an obvious saving and measures as the opposite -- a tight window makes cards leave and re-enter it, and mount churn went from 88 to 155 with blocked time from 3.6s to 4.3s. What this does not do is reduce total blocked time. Roughly three quarters of it is browser style, layout, paint and decode for each card shown, which no amount of scheduling removes. The deep stalls get shorter; the thread stays busy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd --- frontend/css/style.css | 6 ++ frontend/js/videos.js | 162 ++++++++++++++++++++++++++++++++++++++--- tests/smoke_grid.py | 20 +++-- 3 files changed, 172 insertions(+), 16 deletions(-) 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()