From 606250c36101d82e9fedcf9b7d584a45eccbc524 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:58:48 -0500 Subject: [PATCH] resize: reads before writes everywhere, and an open album drags like a closed one --- src/lib/heroGpu.js | 22 +- src/views/CatalogArtist.svelte | 552 +++++++++++++++++++++++---------- 2 files changed, 412 insertions(+), 162 deletions(-) diff --git a/src/lib/heroGpu.js b/src/lib/heroGpu.js index 363d6bd..4fd5a2b 100644 --- a/src/lib/heroGpu.js +++ b/src/lib/heroGpu.js @@ -535,10 +535,26 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ ); const width = Math.max(1, Math.round(rect.width * scale)); const height = Math.max(1, Math.round(rect.height * scale)); - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; + // A store within a couple of percent of the box is not visibly softer — + // that is the same argument reflow() already makes for every + // intermediate frame of a drag, and it holds just as well for the final + // one. Without the tolerance, ANY change reallocates: measured two + // ~52ms hitches landing at 1827ms and 1909ms against a drag that ended + // at 1823ms, i.e. the whole cost of a resize arriving after the user + // had stopped. paint() below is geometrically exact at any store size, + // so skipping the realloc costs nothing but sharpness we cannot see. + const REALLOC_TOLERANCE = 0.02; + const off = (a, b) => Math.abs(a - b) / Math.max(1, b) > REALLOC_TOLERANCE; + if (off(canvas.width, width) || off(canvas.height, height)) { + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + } paint(rect); - gl.finish(); + // gl.finish() blocks the main thread until the GPU has drained, and it + // stalls hardest exactly when the GPU is busy — which, 140ms after a + // drag, it still is. Nothing here reads back, so there is nothing to + // synchronise with; the compositor will show the frame when it is done. + gl.flush(); } /** The ResizeObserver's path, and the reason it is not `resize`. diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 7c3b832..e157d65 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -927,11 +927,19 @@ }); function measureHero(node) { - const update = () => { - if (node.clientWidth > 0) heroWidth = node.clientWidth; - }; - update(); - const observer = new ResizeObserver(update); + if (node.clientWidth > 0) heroWidth = node.clientWidth; + // Width from the ENTRY, not clientWidth: an entry is a post-layout + // fact, while a DOM read in a delivery forces layout whenever an + // earlier callback in the same round has written — and the hero + // reflow writes on every frame of a drag. .hero-zone carries no + // border or padding, so the border box IS the client width. + const observer = new ResizeObserver((entries) => { + const box = entries[entries.length - 1]; + const w = Math.round( + box.borderBoxSize?.[0]?.inlineSize ?? box.contentRect.width, + ); + if (w > 0) heroWidth = w; + }); observer.observe(node); return { destroy: () => observer.disconnect() }; } @@ -1087,8 +1095,21 @@ // The constant part of the content's flow-local fade line — written // by measure(), consumed every frame by apply(). let stripCut = -99999; + // measure()'s write block, deferred one rAF (latest wins). Written + // inside the ResizeObserver delivery, the custom properties dirty + // style mid-round and every later callback's first read — the grid + // reserve's — pays a full forced layout, every frame of a drag. + // Deferred, the writes ride the next frame's one natural layout. + // Every consumer already tolerates the unwritten state (the + // var(--travel, 999999px) sentinels exist for the pre-measure first + // paint), so a one-frame lag is the same situation, briefer. + let commit = null; + let commitRaf = 0; + // The chips' un-parked height, reused on parked non-settle passes so + // a drag deep in the page skips the data-park toggle (see below). + let chipsHCache = -1; - function measure() { + function measure(settled = true) { const zone = page.querySelector(".hero-zone"); if (!zone) return; // NEVER measure the pins themselves. They are `position: sticky`, @@ -1106,9 +1127,19 @@ // is measured from .hero-zone, which is `position: relative` and // cannot lie. // + // READS FIRST, WRITES LAST — this runs on every frame of a window + // drag (the ResizeObserver below), and every style write here + // dirties the very geometry the next read then forces back up to + // date. Interleaved, one pass through this function cost up to six + // forced layouts; batched, it costs one (two while parked, for the + // data-park toggle). Nothing may read layout after the write block + // starts. + // // Measured from the TOP OF THE BAND, so these compare directly // against band-local gradient stops. - const span = zone.offsetTop + zone.offsetHeight - BAND_TOP; + const zoneTop = zone.offsetTop; + const zoneH = zone.offsetHeight; + const span = zoneTop + zoneH - BAND_TOP; // Constant by construction: the band's top sits at scrollport // -24px (BAND_TOP) and the glass bar's stuck bottom at +22px // (BAR_BOTTOM), so the bar clears the band's top by 46px and the @@ -1138,17 +1169,17 @@ const slot = page.querySelector(".logo-slot"); let markRest = 16; let inkPark = markPx; + // Written in the write block below, and only when measured here — + // absent copy/slot the previous values must stand, as they always + // have. + let markEnd = null; if (copy && slot) { markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight; - page.style.setProperty("--mark-rest", `${markRest}px`); // offsetHeight, not a rect: the slot carries the ride-up scale // while scrolled and a rect would feed that back into itself. const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title"); const markHeight = mark?.offsetHeight ?? 0; - page.style.setProperty( - "--mark-end", - markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1", - ); + markEnd = markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1"; inkPark = markHeight > markPx ? markPx : markHeight || markPx; } // The Play/chips row's resting height, MEASURED off the row itself; @@ -1177,97 +1208,53 @@ // reader halfway through a scroll. The toggle is synchronous, so // no paint can land between the two writes. const chipsEl = page.querySelector(".stat-chips"); - const gap = page.querySelector(".actions-gap"); - const parkAttr = page.dataset.park ?? "0"; - page.dataset.park = "0"; - const chipsH = chipsEl?.getBoundingClientRect().height ?? 0; - page.dataset.park = parkAttr; + const gapEl = page.querySelector(".actions-gap"); // The one-line floor, per breakpoint, and the top padding derived // from it (see --row-pad-top). The row is padTop + the chips' - // offset against the 50px lead row + the chips + 8px. + // offset against the 50px lead row + the chips + 8px. Both reads + // sit BEFORE the park toggle: taken after the attribute flip they + // pay the style pass the flip dirties. const floor = parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76; const padTop = (floor - 66) / 2 + 8; + const gapH = chipsEl ? 0 : gapEl?.offsetHeight ?? floor; + // Guarded three ways. At rest data-park is already "0", and even + // a same-value attribute write is a style invalidation a drag + // frame cannot afford. Parked — scrolled deep, which is where the + // discography drags happen — the toggle costs a forced layout to + // un-collapse the chips, then a second when the next read lands + // after the flip back; so mid-drag (settled = false) the last + // measured height is reused and only the trailing settle pass, + // the mount and the data-change remeasures pay the exact read. + // Stale by at most one wrap boundary, above the viewport, for the + // 200ms until the settle pass — and scroll anchoring absorbs the + // correction. + const parkAttr = page.dataset.park ?? "0"; + let chipsH; + if (chipsEl && parkAttr !== "0" && !settled && chipsHCache >= 0) { + chipsH = chipsHCache; + } else { + if (parkAttr !== "0") page.dataset.park = "0"; + chipsH = chipsEl?.getBoundingClientRect().height ?? 0; + if (parkAttr !== "0") page.dataset.park = parkAttr; + chipsHCache = chipsEl ? chipsH : -1; + } const rowH = chipsEl ? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8)) - : gap?.offsetHeight ?? floor; - page.style.setProperty("--row-h", `${rowH}px`); + : gapH; syncSocialDivider(page); // The park point: the content edge stops exactly where the shrunk // wordmark's top lands PAD_TOP_PARKED below the glass bar with the // identity still in its resting slots. const strip = BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest; - page.style.setProperty("--banner-h", `${banner}px`); - // Glue, not travel. --lift cancels .hero-copy's parked term - // exactly, so its bottom stays 0 at every p — the wordmark - // shrinks in place about its resting baseline. The row needs no - // travel var at all any more: its box is pinned to its resting - // slot in CSS. - page.style.setProperty("--lift", `${banner - 14 - markRest}px`); - - // Where the pins sit in flow. the glass bar's net flow height is - // zero (-24 + 46 - 22), so the hero zone starts at exactly the - // same offset the pins do — and it is `position: relative`, so - // asking it cannot feed a sticky offset back into the measurement - // the way asking a pin would. - page.style.setProperty("--pin-origin", `${zone.offsetTop}px`); - page.style.setProperty("--hero-span", `${span}px`); - page.style.setProperty("--strip", `${strip}px`); travel = span - strip > 1 ? span - strip : Infinity; - // The scroll-driven animations' constants (see the second - // @supports (animation-timeline: scroll()) block in the CSS). - // --travel is the shared animation-range span — the p - // denominator — so linear easing over 0..--travel gives - // animation progress ≡ p at every scrollTop. The 999999px - // sentinel mirrors travel = Infinity above (p pinned ~0: no - // animation progresses measurably), and every consumer reads - // var(--travel, 999999px) so the pre-measure first paint is - // inert too. --ride is the SAME delta as a length for - // to-keyframes, and must be a separate variable: tied to the - // range sentinel, a degenerate page's to-value would cancel - // into translate = -scrollTop; the 0px sentinel keeps it - // motionless exactly as p = 0 does today. --fade-span is the - // extras/badge fade's own range (travel / 2.4, where their - // opacity ramp clamps): dividing the 999999px sentinel instead - // would let a deep scroll on a degenerate page ghost-fade chips - // that today hold opacity 1 at any scrollTop. const rideValid = span - strip > 1; - page.style.setProperty( - "--travel", - `${rideValid ? (span - strip).toFixed(1) : 999999}px`, - ); - page.style.setProperty( - "--ride", - `${rideValid ? (span - strip).toFixed(1) : 0}px`, - ); - page.style.setProperty( - "--fade-span", - `${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`, - ); - // The chips row's total leftward slide for the scroll-driven - // path: the sum of the layout width both buttons ahead of it in - // the flex row shed (22 mirrors --play-shrink; 13 is - // .hero-preview's 40→27). On that path the buttons' REAL widths - // are frozen and shrink by transform instead, so the flex row no - // longer supplies this motion. Presence-aware so a - // conditionally-rendered preview button cannot leave a phantom - // 13px slide, and validity-gated like --ride: on a degenerate - // page the range sentinel would otherwise creep the chips - // ~1.75px at extreme scrollTop where today's pinned p holds - // them motionless. (The button/glyph scale rides keep constant - // to-poses — their same-class residual is ≤1.1px on geometry - // the 390px hero-zone minimum makes unreachable; accepted.) - page.style.setProperty( - "--row-slide", - `${ - rideValid - ? (page.querySelector(".hero-play") ? 22 : 0) + - (page.querySelector(".hero-preview") ? 13 : 0) - : 0 - }px`, - ); + // Probed with the other reads, consumed by the --row-slide write + // below. + const hasPlay = !!page.querySelector(".hero-play"); + const hasPreview = !!page.querySelector(".hero-preview"); // ROUND 12 — the sheet's two poses, both in band coordinates. // At rest its gradient sits on the VISIBLE hero (onset at 60% of @@ -1280,18 +1267,6 @@ const fogSpan = Math.round(span * 0.42); const fogAbove = Math.round(fogSpan * 0.294); const fogRest = Math.round(span * 0.6); - page.style.setProperty("--fog-span", `${fogSpan}px`); - page.style.setProperty("--fog-above", `${fogAbove}px`); - page.style.setProperty("--fog-rest", `${fogRest}px`); - // The sheet's pose delta for its scroll-driven glide (usually - // negative): from-pose translateY(--fog-rest), to-pose - // translateY(--fog-rest + --fog-ride) ≡ translateY(strip - - // fog-above) — the same two exact endpoints the fallback calc - // has. 0px sentinel, same reasoning as --ride above. - page.style.setProperty( - "--fog-ride", - `${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`, - ); // The content's fade line, precomputed so apply() is arithmetic // only: flow-local cut = stripCut + scrollTop. The zone must sit @@ -1312,6 +1287,11 @@ // the list rows above the chips' real bottom edge. .stat-chips // rides a translateX (chips-slide-ride), never a translateY, so its // measured bottom is as scroll-invariant as the button's. + // Measured here, ahead of the write block, under the PREVIOUS + // pass's custom properties: the quantity is the ink's offset below + // --edge, which the glue keeps constant at every p and every + // span/strip, so a one-pass-stale pose moves it by at most the + // frame's own drift — and the trailing settle pass reads it clean. const body = page.querySelector(".scrollbody"); const rowInk = [ page.querySelector(".hero-play"), @@ -1352,48 +1332,146 @@ // exists for. The host sits at .scrollbody's top (= the zone's // bottom) with a -18px margin hanging it 18px above the // content top. - if (scrollDriven && body) { - // A sticky inset resolves from inside the pane's padding - // (the same 24px BAR_BOTTOM exists to cancel), so `top: X` - // parks the box at scrollport X + padding — measured live: - // top 217 parked at 241, a fade line one full slot too low - // AND a 24px content jump at the stick moment, since S below - // is derived in scrollport coordinates. Subtracting the - // padding parks the box on the exact line the mask fallback - // draws, and makes the stick moment land precisely at S. - const padTop = - parseFloat(getComputedStyle(scroller).paddingTop) || 0; - page.style.setProperty( - "--cut-top", - `${(cutScreen - padTop).toFixed(1)}px`, - ); - const hostFlowTop = zone.offsetTop + zone.offsetHeight - 18; - const fadeS = Math.max(0, hostFlowTop - cutScreen); - page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`); - } - // Unconditional: .fade-frame is scroll-driven-only, but .sheetclip - // sizes itself from --frame-h on BOTH branches (it is what keeps the - // 300vh sheet out of the pane's scroll range). Setting it on the - // fallback path is otherwise inert — .fade-frame's rule lives inside - // the @supports block and never matches there. - page.style.setProperty("--frame-h", `${scroller.clientHeight}px`); - // The extras' hit-gate sentinel parks at the scrollTop where - // p crosses 0.42. Probed live (2026-07-24, this WebView2): the - // observers' rootBounds top coincides with the page's box top - // at scrollTop 0 — the pane's 24px padding does NOT offset the - // crossing — but the observer reports integer-snapped rects, - // so the -1 below folds the sentinel's own 1px height into the - // placement: the snapped bottom then reaches the root's top at - // the same integer scrollTop the old `p > 0.42` first exceeded - // (probed flip: auto at 377, none at 378, boundary 377.58). - // Off-screen sentinel on a degenerate page, mirroring travel = - // Infinity pinning p at 0 (travelFinite keeps the observers - // honest about that case too). + // The last two geometry reads, then the write block. + const scrollerPadTop = scrollDriven + ? parseFloat(getComputedStyle(scroller).paddingTop) || 0 + : 0; + const frameH = scroller.clientHeight; + + // JS state, not style — apply() and the sentinel observers read + // these synchronously, so they are set with the reads, not + // deferred with the writes. travelFinite = Number.isFinite(travel); - if (s2) { - s2.style.top = travelFinite - ? `${(0.42 * travel - 1).toFixed(1)}px` - : "-99999px"; + + // ── the write block — geometry above, styles below, applied at + // the next rAF (latest measurement wins; see `commit`) ── + commit = () => { + if (markEnd != null) { + page.style.setProperty("--mark-rest", `${markRest}px`); + page.style.setProperty("--mark-end", markEnd); + } + page.style.setProperty("--row-h", `${rowH}px`); + page.style.setProperty("--banner-h", `${banner}px`); + // Glue, not travel. --lift cancels .hero-copy's parked term + // exactly, so its bottom stays 0 at every p — the wordmark + // shrinks in place about its resting baseline. The row needs no + // travel var at all any more: its box is pinned to its resting + // slot in CSS. + page.style.setProperty("--lift", `${banner - 14 - markRest}px`); + // Where the pins sit in flow. the glass bar's net flow height is + // zero (-24 + 46 - 22), so the hero zone starts at exactly the + // same offset the pins do — and it is `position: relative`, so + // asking it cannot feed a sticky offset back into the measurement + // the way asking a pin would. + page.style.setProperty("--pin-origin", `${zoneTop}px`); + page.style.setProperty("--hero-span", `${span}px`); + page.style.setProperty("--strip", `${strip}px`); + // The scroll-driven animations' constants (see the second + // @supports (animation-timeline: scroll()) block in the CSS). + // --travel is the shared animation-range span — the p + // denominator — so linear easing over 0..--travel gives + // animation progress ≡ p at every scrollTop. The 999999px + // sentinel mirrors travel = Infinity above (p pinned ~0: no + // animation progresses measurably), and every consumer reads + // var(--travel, 999999px) so the pre-measure first paint is + // inert too. --ride is the SAME delta as a length for + // to-keyframes, and must be a separate variable: tied to the + // range sentinel, a degenerate page's to-value would cancel + // into translate = -scrollTop; the 0px sentinel keeps it + // motionless exactly as p = 0 does today. --fade-span is the + // extras/badge fade's own range (travel / 2.4, where their + // opacity ramp clamps): dividing the 999999px sentinel instead + // would let a deep scroll on a degenerate page ghost-fade chips + // that today hold opacity 1 at any scrollTop. + page.style.setProperty( + "--travel", + `${rideValid ? (span - strip).toFixed(1) : 999999}px`, + ); + page.style.setProperty( + "--ride", + `${rideValid ? (span - strip).toFixed(1) : 0}px`, + ); + page.style.setProperty( + "--fade-span", + `${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`, + ); + // The chips row's total leftward slide for the scroll-driven + // path: the sum of the layout width both buttons ahead of it in + // the flex row shed (22 mirrors --play-shrink; 13 is + // .hero-preview's 40→27). On that path the buttons' REAL widths + // are frozen and shrink by transform instead, so the flex row no + // longer supplies this motion. Presence-aware so a + // conditionally-rendered preview button cannot leave a phantom + // 13px slide, and validity-gated like --ride: on a degenerate + // page the range sentinel would otherwise creep the chips + // ~1.75px at extreme scrollTop where today's pinned p holds + // them motionless. (The button/glyph scale rides keep constant + // to-poses — their same-class residual is ≤1.1px on geometry + // the 390px hero-zone minimum makes unreachable; accepted.) + page.style.setProperty( + "--row-slide", + `${rideValid ? (hasPlay ? 22 : 0) + (hasPreview ? 13 : 0) : 0}px`, + ); + page.style.setProperty("--fog-span", `${fogSpan}px`); + page.style.setProperty("--fog-above", `${fogAbove}px`); + page.style.setProperty("--fog-rest", `${fogRest}px`); + // The sheet's pose delta for its scroll-driven glide (usually + // negative): from-pose translateY(--fog-rest), to-pose + // translateY(--fog-rest + --fog-ride) ≡ translateY(strip - + // fog-above) — the same two exact endpoints the fallback calc + // has. 0px sentinel, same reasoning as --ride above. + page.style.setProperty( + "--fog-ride", + `${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`, + ); + if (scrollDriven && body) { + // A sticky inset resolves from inside the pane's padding + // (the same 24px BAR_BOTTOM exists to cancel), so `top: X` + // parks the box at scrollport X + padding — measured live: + // top 217 parked at 241, a fade line one full slot too low + // AND a 24px content jump at the stick moment, since S below + // is derived in scrollport coordinates. Subtracting the + // padding parks the box on the exact line the mask fallback + // draws, and makes the stick moment land precisely at S. + page.style.setProperty( + "--cut-top", + `${(cutScreen - scrollerPadTop).toFixed(1)}px`, + ); + const hostFlowTop = zoneTop + zoneH - 18; + const fadeS = Math.max(0, hostFlowTop - cutScreen); + page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`); + } + // Unconditional: .fade-frame is scroll-driven-only, but .sheetclip + // sizes itself from --frame-h on BOTH branches (it is what keeps the + // 300vh sheet out of the pane's scroll range). Setting it on the + // fallback path is otherwise inert — .fade-frame's rule lives inside + // the @supports block and never matches there. + page.style.setProperty("--frame-h", `${frameH}px`); + // The extras' hit-gate sentinel parks at the scrollTop where + // p crosses 0.42. Probed live (2026-07-24, this WebView2): the + // observers' rootBounds top coincides with the page's box top + // at scrollTop 0 — the pane's 24px padding does NOT offset the + // crossing — but the observer reports integer-snapped rects, + // so the -1 below folds the sentinel's own 1px height into the + // placement: the snapped bottom then reaches the root's top at + // the same integer scrollTop the old `p > 0.42` first exceeded + // (probed flip: auto at 377, none at 378, boundary 377.58). + // Off-screen sentinel on a degenerate page, mirroring travel = + // Infinity pinning p at 0 (travelFinite keeps the observers + // honest about that case too). + if (s2) { + s2.style.top = travelFinite + ? `${(0.42 * travel - 1).toFixed(1)}px` + : "-99999px"; + } + }; + if (!commitRaf) { + commitRaf = requestAnimationFrame(() => { + commitRaf = 0; + const c = commit; + commit = null; + c?.(); + }); } } @@ -1438,8 +1516,8 @@ if (!frame) frame = requestAnimationFrame(apply); } - function reflow() { - measure(); + function reflow(settled = true) { + measure(settled); // Supported path: nothing per-frame is left to apply — the // observers re-deliver on their own once measure() has moved // the sentinels, and running apply() here would pay the exact @@ -1580,7 +1658,9 @@ // reschedules itself, so there is no loop. let settleTimer = 0; const reflowAndSettle = () => { - reflow(); + // Mid-drag pass: parked chips reuse their cached height. The + // trailing pass (bare reflow, settled = true) measures exactly. + reflow(false); clearTimeout(settleTimer); settleTimer = setTimeout(reflow, 200); }; @@ -1653,6 +1733,8 @@ s1?.remove(); s2?.remove(); if (frame) cancelAnimationFrame(frame); + if (commitRaf) cancelAnimationFrame(commitRaf); + commit = null; }, }; } @@ -2730,8 +2812,36 @@ // what keeps the reveal's per-frame re-stand authoritative. let glide = 0; let glideTo = null; + // "Instant" writes land in the NEXT rAF, never inside the + // ResizeObserver delivery that asked for them. A write during + // delivery makes the observer loop re-run style and layout in the + // SAME frame to check for further changes — a whole extra layout + // pass per frame, paid on every frame of a drag while the reserve + // tracks the moving margin. Deferred one rAF, the write rides the + // frame's single natural layout, and the echo delivery it provokes + // is filtered by the entry guard below without a DOM read. A frame + // of lag on a min-height floor is invisible — and the reveal never + // waits on this path: it stands its own reserve synchronously. + let pendingTo = null; + let flushRaf = 0; + const flushReserve = () => { + flushRaf = 0; + if (pendingTo == null || !node.isConnected) { + pendingTo = null; + return; + } + const standing = parseFloat(node.style.minHeight) || 0; + const to = pendingTo; + pendingTo = null; + // Never lower under a running reveal: it may have raised the + // standing value past this queued figure since the queueing pass, + // and dropping beneath it would clamp the pane mid-ease. + if (revealActive && to < standing) return; + if (to !== standing) node.style.minHeight = `${to}px`; + }; const setReserve = (to) => { - const from = parseFloat(node.style.minHeight) || 0; + const standing = parseFloat(node.style.minHeight) || 0; + const from = pendingTo ?? standing; if (glide) { if (to <= glideTo) return; // already heading there or lower cancelAnimationFrame(glide); @@ -2747,9 +2857,12 @@ // fall happens afterwards, from retire(), where it can glide. if (revealActive && to < from) return; if (to >= from - 2 || revealActive) { - if (node.style.minHeight !== `${to}px`) node.style.minHeight = `${to}px`; + if (pendingTo === to || (pendingTo == null && standing === to)) return; + pendingTo = to; + if (!flushRaf) flushRaf = requestAnimationFrame(flushReserve); return; } + pendingTo = null; glideTo = to; const start = performance.now(); const DUR = 260; @@ -2757,24 +2870,63 @@ if (!node.isConnected) { glide = 0; glideTo = null; return; } const t = Math.min(1, (now - start) / DUR); const ease = 1 - (1 - t) ** 3; - node.style.minHeight = `${Math.round(from + (to - from) * ease)}px`; + node.style.minHeight = `${Math.round(standing + (to - standing) * ease)}px`; if (t < 1) { glide = requestAnimationFrame(step); return; } glide = 0; glideTo = null; }); }; + // Found once, re-queried only after a teardown replaced them: the + // pane and the tab bar cannot move while this grid is mounted. + let cachedPane = null; + let cachedBar = null; + // The reveal reserve's expensive half — the open row's offset inside + // the grid. Width alone cannot move it (tracks are fixed 150px): it + // moves when the expander changes ROW — a column-count change, a + // different album, art landing above it. Recomputing it on every + // frame of a drag paid rects plus a subtree sweep 48 times for a + // handful of distinct answers, and was most of the difference between + // ~20ms and ~60ms p95 frames with a panel open. Keyed on exactly what + // invalidates it; forced() clears the key for the DOM-driven cases + // (open, row change, close, art landing). + let rowOffCols = -1; + let rowOffXp = null; + let rowOffRow = null; + let rowOff = 0; + // A column-count change moves the expander only on Svelte's NEXT + // flush, so an offset read in the same pass sees the old layout. + let colsSettling = false; + const measure = () => { const n = getComputedStyle(node).gridTemplateColumns.split(" ").length; - if (gridCols[groupKey] !== n) gridCols = { ...gridCols, [groupKey]: n }; + if (gridCols[groupKey] !== n) { + gridCols = { ...gridCols, [groupKey]: n }; + // Re-measure once the expander has landed on its new row — + // the offset cached this pass is against a layout that is + // about to change. (Today's per-frame recompute healed on the + // next drag frame; a drag that ENDS on a column change has no + // next frame, so this settle pass also closes that hole.) + if (!colsSettling && node.querySelector(".album-expander")) { + colsSettling = true; + void tick().then(() => { + colsSettling = false; + if (node.isConnected) forced(); + }); + } + } - const pane = scrollPaneFor(node); + if (!cachedPane?.isConnected) cachedPane = scrollPaneFor(node); // Anchor on the TAB BAR, not the heading: the bar is what // `selectTab` pins, and the worst case is the bar sitting flush // at the pane's top. Everything below it (the bar itself + this // grid) must then still fill a pane-height, or the scroll clamps. - const bar = node.closest(".pane")?.querySelector(".disco-tabs") - ?? document.querySelector(".disco-tabs"); + if (!cachedBar?.isConnected) { + cachedBar = node.closest(".pane")?.querySelector(".disco-tabs") + ?? document.querySelector(".disco-tabs"); + } + const pane = cachedPane; + const bar = cachedBar; if (!pane || !bar) return; const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top; let reserve = Math.max(0, Math.round(pane.clientHeight - chrome)); @@ -2799,18 +2951,82 @@ const xp = node.querySelector(".album-expander:not([inert])") ?? node.querySelector(".album-expander"); const openRow = xp?.previousElementSibling; - if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane, xp)); + if (openRow) { + if (n !== rowOffCols || xp !== rowOffXp || openRow !== rowOffRow) { + rowOff = revealRowOffset(node, openRow, xp); + rowOffCols = n; + rowOffXp = xp; + rowOffRow = openRow; + } + // The cheap half stays live every frame: the pane's height and + // the banner margin move with the window, the row offset does + // not. + reserve = Math.max( + reserve, + Math.round(rowOff + pane.clientHeight - revealMargin(node)), + ); + } setReserve(reserve); }; + // The resize path still runs EVERY frame — the grid keeps scaling live. + // What it stops doing is repeating work whose inputs have not moved. + // + // The reserve is a pure function of the grid's width and the pane's + // height. When neither has changed since the last pass — which is every + // observer callback provoked by our own minHeight write, and every + // vertical-only resize — recomputing it cannot produce a different + // answer, so the guard is exact rather than approximate. + // + // Sizes come from the ENTRIES, never from clientWidth/clientHeight: an + // entry is a post-layout fact, but a DOM read here forces layout + // whenever anything earlier in the same delivery has written — and the + // reserve's own write provokes an echo delivery every time it moves. + // The pane is observed alongside the grid for the same reason; its + // height rides in with the entries and the echo path never touches + // the DOM at all. + let lastW = -1; + let lastPaneH = -1; + const onResize = (entries) => { + let w = lastW; + let paneH = lastPaneH; + for (const entry of entries) { + const box = entry.borderBoxSize?.[0]; + if (entry.target === node) { + w = Math.round(box ? box.inlineSize : entry.contentRect.width); + } else { + paneH = Math.round(box ? box.blockSize : entry.contentRect.height); + } + } + if (w === lastW && paneH === lastPaneH) return; + lastW = w; + lastPaneH = paneH; + measure(); + }; + // Anything that changes the DOM rather than the box — an expander + // opening, a row change, a close — must not be filtered by that guard: + // its inputs are unchanged but its answer is not. The row-offset cache + // is keyed on the same DOM, so it drops here too. + const forced = () => { + lastW = -1; + lastPaneH = -1; + rowOffCols = -1; + rowOffXp = null; + rowOffRow = null; + measure(); + }; + measure(); - gridMeasures.add(measure); - const ro = new ResizeObserver(measure); + gridMeasures.add(forced); + const ro = new ResizeObserver(onResize); ro.observe(node); + const paneEl = cachedPane ?? scrollPaneFor(node); + if (paneEl) ro.observe(paneEl); return { destroy: () => { ro.disconnect(); - gridMeasures.delete(measure); + gridMeasures.delete(forced); cancelAnimationFrame(glide); + cancelAnimationFrame(flushRaf); }, }; } @@ -3280,6 +3496,16 @@ * that then walked back toward it, and the page moved three times. */ function vanishingAbove(grid, row, keep) { if (!grid) return 0; + // Gather before measuring: with a single expander standing — every + // frame outside the 460ms of a row switch — there is nothing on its + // way out, and the rect and style reads below would be paid for an + // answer that is always zero. This runs on every frame of a reveal + // ease, so the empty case must cost only the subtree query. + let others = null; + for (const other of grid.querySelectorAll(".album-expander")) { + if (other !== keep) (others ??= []).push(other); + } + if (!others) return 0; const rowTop = row.getBoundingClientRect().top; // The expander is a GRID ITEM on a row of its own, so the row-gap that // separates it from its neighbour goes with it. Counting only its @@ -3291,8 +3517,7 @@ // is exactly what makes a 10px move read as a bump. const gap = parseFloat(getComputedStyle(grid).rowGap) || 0; let px = 0; - for (const other of grid.querySelectorAll(".album-expander")) { - if (other === keep) continue; + for (const other of others) { const r = other.getBoundingClientRect(); // Its whole outer contribution: height, its own collapse margin, // and the row-gap only if the grid still uses one. expandSlide @@ -3304,11 +3529,20 @@ return px; } - function revealReserve(grid, row, pane, keep) { - const rowInGrid = row.getBoundingClientRect().top + /** The open row's offset inside the grid, net of anything mid-outro above + * it — the half of the reserve that costs layout reads. Split from + * revealReserve so measureCols can cache it across a drag: width alone + * cannot move it (tracks are fixed 150px), only a row change can. */ + function revealRowOffset(grid, row, keep) { + return row.getBoundingClientRect().top - grid.getBoundingClientRect().top - vanishingAbove(grid, row, keep); - return Math.round(rowInGrid + pane.clientHeight - revealMargin(grid)); + } + + function revealReserve(grid, row, pane, keep) { + return Math.round( + revealRowOffset(grid, row, keep) + pane.clientHeight - revealMargin(grid), + ); } function revealIntoView(node) {