From 6f372d36f8ea43dd2af23fda96646c8088ac7d2f Mon Sep 17 00:00:00 2001 From: yuhao Date: Thu, 13 Aug 2026 04:30:13 +0000 Subject: [PATCH] perf(hub): batch the phone deck and skip toolbar-resize rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast scrolling on iPhone could still kill the tab after the GIF/video and content-visibility work. Two remaining causes only real iOS exhibits: - iOS Safari fires a resize when its toolbar collapses mid-scroll, and the handler rebuilt both masonry grids (teardown + re-append of 101 cards) during the fling. Masonry columns depend only on width, so the handler now bails when the width is unchanged. - Phones laid out all 79 harness cards (~28k px of live DOM) up front — content-visibility skips paint but the initial masonry measurement still forces layout of every card, and a fling still rasterizes the whole run. Single-column layouts now render the deck in batches of 20 that grow via a sentinel IntersectionObserver (1600px margin) as the user approaches; the mobile deck placeholder shrinks to match. Multi-column desktop masonry still renders the full set so shortest-column balancing is exact. jumpToTool materializes remaining batches before scrolling to a target card; search/filter/sort reset to the first batch. Chromium renderer RSS (390x844@3x fling scenario): load 297MB -> 171MB, peak 807MB -> 380MB vs the original page. Full cross-engine suite passes (WebKit + Chromium, mobile + desktop): batch growth while scrolling, reset-on-search, jumpToTool into late batches, footer reachable at true bottom, video rings, flip lifecycle, no overflow, zero page errors. --- docs/hub/index.html | 66 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/docs/hub/index.html b/docs/hub/index.html index 01420f640..d661f2bd0 100644 --- a/docs/hub/index.html +++ b/docs/hub/index.html @@ -440,7 +440,7 @@ .marketplace-prelude { contain-intrinsic-size: 220px; contain-intrinsic-size: auto 220px; } .matrices { contain-intrinsic-size: 900px; contain-intrinsic-size: auto 900px; } .deck-stage { contain-intrinsic-size: 9500px; contain-intrinsic-size: auto 9500px; } - @media (max-width: 640px) { .deck-stage { contain-intrinsic-size: 28000px; contain-intrinsic-size: auto 28000px; } } + @media (max-width: 640px) { .deck-stage { contain-intrinsic-size: 7200px; contain-intrinsic-size: auto 7200px; } } .footer { contain-intrinsic-size: 320px; contain-intrinsic-size: auto 320px; } .deck-stage { max-width: 1180px; margin: 0 auto; padding: 0 22px; } @@ -456,6 +456,7 @@ between decks. (Replaces the old over-wide "peek" carousel that mis-measured its width and clipped the active panel on the right while bleeding the other on the left.) */ .deck-panel { flex: 0 0 100%; min-width: 0; position: relative; } + .deck-sentinel { height: 1px; } /* Perf — Safari especially. While the deck moves we collapse every effect that forces a per-frame re-rasterization of the large card subtree, so each panel caches as a flat layer the GPU can translate cheaply. All effects return the moment motion stops. @@ -1254,6 +1255,7 @@
+ @@ -1271,6 +1273,7 @@
+ @@ -2055,13 +2058,56 @@ const grid = document.getElementById('grid-' + deck); if (filtered.length === 0) { grid.innerHTML = '
No CLIs match your search.
'; + state.fullList = []; state.shownCount = 0; + updateDeckSentinel(deck); return; } - if (deck === 'harness') grid.innerHTML = filtered.map(renderHarnessCard).join(''); - else grid.innerHTML = filtered.map(renderNpmCard).join(''); + // Phones (single-column) render in growing batches: a full 79-card render + // means ~28k px of live DOM, and laying out + rasterizing it during a fast + // fling is what killed the tab on iPhones. Desktop masonry keeps the full + // set so its shortest-column balancing stays exact. + state.fullList = filtered; + state.shownCount = Math.min(deckBatchLimit(grid), filtered.length); + const shown = filtered.slice(0, state.shownCount); + if (deck === 'harness') grid.innerHTML = shown.map(renderHarnessCard).join(''); + else grid.innerHTML = shown.map(renderNpmCard).join(''); layoutMasonry(grid); + updateDeckSentinel(deck); } + // ── Progressive deck batches (phones) ── + const DECK_BATCH_SIZE = 20; + function deckBatchLimit(grid) { + // Batch only in single-column layout and only when IO can grow the list. + return (deckSentinelIO && masonryColumnCount(grid) === 1) ? DECK_BATCH_SIZE : Infinity; + } + function appendDeckBatch(deck) { + const state = deckState[deck]; + if (!state.fullList || state.shownCount >= state.fullList.length) return; + const grid = document.getElementById('grid-' + deck); + const next = state.fullList.slice(state.shownCount, state.shownCount + DECK_BATCH_SIZE); + state.shownCount += next.length; + grid.insertAdjacentHTML('beforeend', next.map(deck === 'harness' ? renderHarnessCard : renderNpmCard).join('')); + layoutMasonry(grid); + updateDeckSentinel(deck); + } + function updateDeckSentinel(deck) { + const s = document.getElementById('sentinel-' + deck); + if (!s || !deckSentinelIO) return; + const state = deckState[deck]; + const more = !!(state.fullList && state.shownCount < state.fullList.length); + s.hidden = !more; + // Re-observe so IO re-evaluates even when the sentinel never left the + // margin band (otherwise a still-intersecting sentinel fires no new event). + deckSentinelIO.unobserve(s); + if (more) deckSentinelIO.observe(s); + } + const deckSentinelIO = ('IntersectionObserver' in window) + ? new IntersectionObserver((entries) => { + entries.forEach((en) => { if (en.isIntersecting) appendDeckBatch(en.target.dataset.deck); }); + }, { rootMargin: '1600px 0px' }) + : null; + // ── Engine-independent JS masonry: shortest-column distribution ── // Rebuilds the grid's .grid-col wrappers from the current set of .card elements and // drops each next card into the currently shortest column. Idempotent / re-runnable. @@ -2368,6 +2414,13 @@ const si = document.getElementById('search-' + deck); if (si) si.value = ''; setFilter(deck, 'all'); switchDeck(deck); + // phones render the deck in batches — materialize until the target exists + let guard = 0; + while (!document.getElementById('tool-' + cssId(name)) && + deckState[deck].fullList && deckState[deck].shownCount < deckState[deck].fullList.length && + guard++ < 50) { + appendDeckBatch(deck); + } requestAnimationFrame(() => { const el = document.getElementById('tool-' + cssId(name)); if (el) { @@ -2774,8 +2827,15 @@ }); let masonryResizeTimer = null; + let masonryLastWidth = window.innerWidth; window.addEventListener('resize', () => { updateDeckPosition(); + // iOS Safari fires resize when its toolbar collapses during scroll — a + // height-only change. Rebuilding both masonry grids mid-fling is a huge + // main-thread stall for a layout that cannot have changed (columns depend + // only on width), so bail unless the width actually moved. + if (window.innerWidth === masonryLastWidth) return; + masonryLastWidth = window.innerWidth; // debounce the (re)layout: recompute column count + redistribute cards clearTimeout(masonryResizeTimer); masonryResizeTimer = setTimeout(relayoutAllGrids, 150);