resize: reads before writes everywhere, and an open album drags like a closed one

This commit is contained in:
KeelrLeek committed 2026-07-31 16:58:48 -05:00
1 parent 52f5878297
commit 606250c361
2 files changed
+412 -162

No files matched your search

+19 -3
View File
@@ -535,10 +535,26 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ
); );
const width = Math.max(1, Math.round(rect.width * scale)); const width = Math.max(1, Math.round(rect.width * scale));
const height = Math.max(1, Math.round(rect.height * scale)); const height = Math.max(1, Math.round(rect.height * scale));
if (canvas.width !== width) canvas.width = width; // A store within a couple of percent of the box is not visibly softer —
if (canvas.height !== height) canvas.height = height; // 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); 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`. /** The ResizeObserver's path, and the reason it is not `resize`.
+393 -159
View File
@@ -927,11 +927,19 @@
}); });
function measureHero(node) { function measureHero(node) {
const update = () => { if (node.clientWidth > 0) heroWidth = node.clientWidth;
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
update(); // earlier callback in the same round has written — and the hero
const observer = new ResizeObserver(update); // 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); observer.observe(node);
return { destroy: () => observer.disconnect() }; return { destroy: () => observer.disconnect() };
} }
@@ -1087,8 +1095,21 @@
// The constant part of the content's flow-local fade line — written // The constant part of the content's flow-local fade line — written
// by measure(), consumed every frame by apply(). // by measure(), consumed every frame by apply().
let stripCut = -99999; 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"); const zone = page.querySelector(".hero-zone");
if (!zone) return; if (!zone) return;
// NEVER measure the pins themselves. They are `position: sticky`, // NEVER measure the pins themselves. They are `position: sticky`,
@@ -1106,9 +1127,19 @@
// is measured from .hero-zone, which is `position: relative` and // is measured from .hero-zone, which is `position: relative` and
// cannot lie. // 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 // Measured from the TOP OF THE BAND, so these compare directly
// against band-local gradient stops. // 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 // Constant by construction: the band's top sits at scrollport
// -24px (BAND_TOP) and the glass bar's stuck bottom at +22px // -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 // (BAR_BOTTOM), so the bar clears the band's top by 46px and the
@@ -1138,17 +1169,17 @@
const slot = page.querySelector(".logo-slot"); const slot = page.querySelector(".logo-slot");
let markRest = 16; let markRest = 16;
let inkPark = markPx; 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) { if (copy && slot) {
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight; 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 // offsetHeight, not a rect: the slot carries the ride-up scale
// while scrolled and a rect would feed that back into itself. // while scrolled and a rect would feed that back into itself.
const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title"); const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title");
const markHeight = mark?.offsetHeight ?? 0; const markHeight = mark?.offsetHeight ?? 0;
page.style.setProperty( markEnd = markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1";
"--mark-end",
markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1",
);
inkPark = markHeight > markPx ? markPx : markHeight || markPx; inkPark = markHeight > markPx ? markPx : markHeight || markPx;
} }
// The Play/chips row's resting height, MEASURED off the row itself; // 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 // reader halfway through a scroll. The toggle is synchronous, so
// no paint can land between the two writes. // no paint can land between the two writes.
const chipsEl = page.querySelector(".stat-chips"); const chipsEl = page.querySelector(".stat-chips");
const gap = page.querySelector(".actions-gap"); const gapEl = page.querySelector(".actions-gap");
const parkAttr = page.dataset.park ?? "0";
page.dataset.park = "0";
const chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
page.dataset.park = parkAttr;
// The one-line floor, per breakpoint, and the top padding derived // The one-line floor, per breakpoint, and the top padding derived
// from it (see --row-pad-top). The row is padTop + the chips' // 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 = const floor =
parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76; parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76;
const padTop = (floor - 66) / 2 + 8; 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 const rowH = chipsEl
? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8)) ? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8))
: gap?.offsetHeight ?? floor; : gapH;
page.style.setProperty("--row-h", `${rowH}px`);
syncSocialDivider(page); syncSocialDivider(page);
// The park point: the content edge stops exactly where the shrunk // The park point: the content edge stops exactly where the shrunk
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the // wordmark's top lands PAD_TOP_PARKED below the glass bar with the
// identity still in its resting slots. // identity still in its resting slots.
const strip = const strip =
BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest; 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; 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; const rideValid = span - strip > 1;
page.style.setProperty( // Probed with the other reads, consumed by the --row-slide write
"--travel", // below.
`${rideValid ? (span - strip).toFixed(1) : 999999}px`, const hasPlay = !!page.querySelector(".hero-play");
); const hasPreview = !!page.querySelector(".hero-preview");
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`,
);
// ROUND 12 — the sheet's two poses, both in band coordinates. // ROUND 12 — the sheet's two poses, both in band coordinates.
// At rest its gradient sits on the VISIBLE hero (onset at 60% of // 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 fogSpan = Math.round(span * 0.42);
const fogAbove = Math.round(fogSpan * 0.294); const fogAbove = Math.round(fogSpan * 0.294);
const fogRest = Math.round(span * 0.6); 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 // The content's fade line, precomputed so apply() is arithmetic
// only: flow-local cut = stripCut + scrollTop. The zone must sit // 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 // the list rows above the chips' real bottom edge. .stat-chips
// rides a translateX (chips-slide-ride), never a translateY, so its // rides a translateX (chips-slide-ride), never a translateY, so its
// measured bottom is as scroll-invariant as the button's. // 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 body = page.querySelector(".scrollbody");
const rowInk = [ const rowInk = [
page.querySelector(".hero-play"), page.querySelector(".hero-play"),
@@ -1352,48 +1332,146 @@
// exists for. The host sits at .scrollbody's top (= the zone's // exists for. The host sits at .scrollbody's top (= the zone's
// bottom) with a -18px margin hanging it 18px above the // bottom) with a -18px margin hanging it 18px above the
// content top. // content top.
if (scrollDriven && body) { // The last two geometry reads, then the write block.
// A sticky inset resolves from inside the pane's padding const scrollerPadTop = scrollDriven
// (the same 24px BAR_BOTTOM exists to cancel), so `top: X` ? parseFloat(getComputedStyle(scroller).paddingTop) || 0
// parks the box at scrollport X + padding — measured live: : 0;
// top 217 parked at 241, a fade line one full slot too low const frameH = scroller.clientHeight;
// AND a 24px content jump at the stick moment, since S below
// is derived in scrollport coordinates. Subtracting the // JS state, not style — apply() and the sentinel observers read
// padding parks the box on the exact line the mask fallback // these synchronously, so they are set with the reads, not
// draws, and makes the stick moment land precisely at S. // deferred with the writes.
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).
travelFinite = Number.isFinite(travel); travelFinite = Number.isFinite(travel);
if (s2) {
s2.style.top = travelFinite // ── the write block — geometry above, styles below, applied at
? `${(0.42 * travel - 1).toFixed(1)}px` // the next rAF (latest measurement wins; see `commit`) ──
: "-99999px"; 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); if (!frame) frame = requestAnimationFrame(apply);
} }
function reflow() { function reflow(settled = true) {
measure(); measure(settled);
// Supported path: nothing per-frame is left to apply — the // Supported path: nothing per-frame is left to apply — the
// observers re-deliver on their own once measure() has moved // observers re-deliver on their own once measure() has moved
// the sentinels, and running apply() here would pay the exact // the sentinels, and running apply() here would pay the exact
@@ -1580,7 +1658,9 @@
// reschedules itself, so there is no loop. // reschedules itself, so there is no loop.
let settleTimer = 0; let settleTimer = 0;
const reflowAndSettle = () => { 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); clearTimeout(settleTimer);
settleTimer = setTimeout(reflow, 200); settleTimer = setTimeout(reflow, 200);
}; };
@@ -1653,6 +1733,8 @@
s1?.remove(); s1?.remove();
s2?.remove(); s2?.remove();
if (frame) cancelAnimationFrame(frame); if (frame) cancelAnimationFrame(frame);
if (commitRaf) cancelAnimationFrame(commitRaf);
commit = null;
}, },
}; };
} }
@@ -2730,8 +2812,36 @@
// what keeps the reveal's per-frame re-stand authoritative. // what keeps the reveal's per-frame re-stand authoritative.
let glide = 0; let glide = 0;
let glideTo = null; 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 setReserve = (to) => {
const from = parseFloat(node.style.minHeight) || 0; const standing = parseFloat(node.style.minHeight) || 0;
const from = pendingTo ?? standing;
if (glide) { if (glide) {
if (to <= glideTo) return; // already heading there or lower if (to <= glideTo) return; // already heading there or lower
cancelAnimationFrame(glide); cancelAnimationFrame(glide);
@@ -2747,9 +2857,12 @@
// fall happens afterwards, from retire(), where it can glide. // fall happens afterwards, from retire(), where it can glide.
if (revealActive && to < from) return; if (revealActive && to < from) return;
if (to >= from - 2 || revealActive) { 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; return;
} }
pendingTo = null;
glideTo = to; glideTo = to;
const start = performance.now(); const start = performance.now();
const DUR = 260; const DUR = 260;
@@ -2757,24 +2870,63 @@
if (!node.isConnected) { glide = 0; glideTo = null; return; } if (!node.isConnected) { glide = 0; glideTo = null; return; }
const t = Math.min(1, (now - start) / DUR); const t = Math.min(1, (now - start) / DUR);
const ease = 1 - (1 - t) ** 3; 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; } if (t < 1) { glide = requestAnimationFrame(step); return; }
glide = 0; glide = 0;
glideTo = null; 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 measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length; 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 // Anchor on the TAB BAR, not the heading: the bar is what
// `selectTab` pins, and the worst case is the bar sitting flush // `selectTab` pins, and the worst case is the bar sitting flush
// at the pane's top. Everything below it (the bar itself + this // at the pane's top. Everything below it (the bar itself + this
// grid) must then still fill a pane-height, or the scroll clamps. // grid) must then still fill a pane-height, or the scroll clamps.
const bar = node.closest(".pane")?.querySelector(".disco-tabs") if (!cachedBar?.isConnected) {
?? document.querySelector(".disco-tabs"); cachedBar = node.closest(".pane")?.querySelector(".disco-tabs")
?? document.querySelector(".disco-tabs");
}
const pane = cachedPane;
const bar = cachedBar;
if (!pane || !bar) return; if (!pane || !bar) return;
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top; const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome)); let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
@@ -2799,18 +2951,82 @@
const xp = node.querySelector(".album-expander:not([inert])") const xp = node.querySelector(".album-expander:not([inert])")
?? node.querySelector(".album-expander"); ?? node.querySelector(".album-expander");
const openRow = xp?.previousElementSibling; 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); 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(); measure();
gridMeasures.add(measure); gridMeasures.add(forced);
const ro = new ResizeObserver(measure); const ro = new ResizeObserver(onResize);
ro.observe(node); ro.observe(node);
const paneEl = cachedPane ?? scrollPaneFor(node);
if (paneEl) ro.observe(paneEl);
return { return {
destroy: () => { destroy: () => {
ro.disconnect(); ro.disconnect();
gridMeasures.delete(measure); gridMeasures.delete(forced);
cancelAnimationFrame(glide); cancelAnimationFrame(glide);
cancelAnimationFrame(flushRaf);
}, },
}; };
} }
@@ -3280,6 +3496,16 @@
* that then walked back toward it, and the page moved three times. */ * that then walked back toward it, and the page moved three times. */
function vanishingAbove(grid, row, keep) { function vanishingAbove(grid, row, keep) {
if (!grid) return 0; 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; const rowTop = row.getBoundingClientRect().top;
// The expander is a GRID ITEM on a row of its own, so the row-gap that // 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 // 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. // is exactly what makes a 10px move read as a bump.
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0; const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
let px = 0; let px = 0;
for (const other of grid.querySelectorAll(".album-expander")) { for (const other of others) {
if (other === keep) continue;
const r = other.getBoundingClientRect(); const r = other.getBoundingClientRect();
// Its whole outer contribution: height, its own collapse margin, // Its whole outer contribution: height, its own collapse margin,
// and the row-gap only if the grid still uses one. expandSlide // and the row-gap only if the grid still uses one. expandSlide
@@ -3304,11 +3529,20 @@
return px; return px;
} }
function revealReserve(grid, row, pane, keep) { /** The open row's offset inside the grid, net of anything mid-outro above
const rowInGrid = row.getBoundingClientRect().top * 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 - grid.getBoundingClientRect().top
- vanishingAbove(grid, row, keep); - 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) { function revealIntoView(node) {