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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-09 19:04:10 +00:00
parent e603111d70
commit 6e5ab68a94
3 changed files with 172 additions and 16 deletions

View File

@@ -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()