perf(nav): stop re-measuring the whole page on every scroll frame — fixes the sluggish deck - #51
Merged
Merged
Conversation
…he zone probe made the deck sluggish The owner, on prod: "the slide animation & transition is way worse than the previous build. it's almost like sluggish." Correct, and this is the cause. I shipped it in #50 while fixing a contrast bug on the phone, and it is the one change in that PR that was NOT phone-scoped — so it slowed the desktop deck, which was never the thing under review. WHAT WAS WRONG. The nav decides its glass from what is painted behind it, and that probe ran inside the scroll handler — i.e. on every animation frame of every scroll, and a deck slide transition is a ~500ms smooth scroll, so every frame of one. Per frame it did: document.querySelectorAll('main > section > *') a fresh DOM query nav.getBoundingClientRect() x2 (the old probe() read it twice) for each candidate: getBoundingClientRect() forces layout getComputedStyle().backgroundColor forces style recalc Interleaving rect reads with style reads is the textbook layout-thrash pattern: each style read forces the layout the previous rect read just invalidated, so the cost is not N reads but N synchronous layouts. The handler this replaced did one offsetHeight and two rect reads. WHY IT CAN ALL BE HOISTED. Nothing that probe measures changes while you scroll. A panel's DOCUMENT-space top and bottom are fixed, its background colour is fixed, and the nav is position:fixed so its centre line is scroll-invariant. Only scrollY moves. So the measurement now happens once, in measureBackdrops(), and the per-frame path is a loop over a cached array of {top, bottom, luminance} bands plus one scrollY read — which does not force layout. DOM reads per frame: from 1 query + 2 + 2N to zero. STALENESS IS HANDLED WHERE IT ACTUALLY OCCURS, not by re-reading everything constantly: resize re-measures (scroll no longer shares that handler — them sharing it is how the measurement got into the scroll path), and a ResizeObserver on <main> catches the document changing height without a resize, which it does when the terrain canvas and images settle after first paint. Both fire rarely and neither fires during a scroll. The listener and the observer are hoisted to the teardown's scope so they are removed on every astro:page-load, or each View Transition would stack another observer on the same <main>. BEHAVIOUR IS UNCHANGED — same 0.35 luminance split, same descent-fraction fallback, same DARK_FROM. This is purely when the work happens. The contrast fix it was written for still stands (nav labels 3.98:1 -> 11.8-13.4:1); it just no longer costs a layout per frame to keep. NOT MEASURED, and I want that on the record: no browser pass. The owner's machine was taken down earlier by the headless fleet this branch leaned on and they asked for no more browsers, so the claim here is structural — a read count from the code, not a frame time. It wants the owner's eye on prod, or one profiling run when that is welcome again. Build clean, 813 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The owner, on prod after #50: "the slide animation & transition is way worse than the previous build. it's almost like sluggish."
Correct, and this is the cause. It's mine — shipped in #50 while fixing a phone contrast bug, and it's the one change in that PR that was not phone-scoped, so it slowed the desktop deck, which was never under review.
What was wrong
The nav picks its glass from what's actually painted behind it. That probe ran inside the scroll handler — on every animation frame of every scroll, and a deck slide transition is a ~500ms smooth scroll, so every frame of one. Per frame:
Interleaving rect reads with style reads is the textbook layout-thrash pattern: each style read forces the layout the previous rect read just invalidated, so the cost isn't N reads, it's N synchronous layouts per frame. The handler this replaced did one
offsetHeightand two rect reads.Why all of it can be hoisted
Nothing that probe measures changes while you scroll. A panel's document-space top and bottom are fixed, its background colour is fixed, and the nav is
position: fixedso its centre line is scroll-invariant. OnlyscrollYmoves.So the measurement moves into
measureBackdrops(), called once, and the per-frame path becomes a loop over a cached array of{top, bottom, luminance}bands plus onescrollYread — which does not force layout.DOM reads per frame: from
1 query + 2 + 2Nto zero.Staleness handled where it actually happens
Rather than re-reading everything constantly:
<main>catches the document changing height without a resize, which it does when the terrain canvas and images settle after first paintBoth fire rarely and neither fires during a scroll. The listener and observer are hoisted to the teardown's scope so they're removed on every
astro:page-load— otherwise each View Transition would stack another observer on the same<main>.Behaviour is unchanged
Same 0.35 luminance split, same descent-fraction fallback, same
DARK_FROMthreshold. This changes only when the work happens. The contrast fix it was written for still stands (nav labels 3.98:1 → 11.8–13.4:1); it just no longer costs a layout per frame to keep.Gates
npm run buildclean, 813 tests green.Not measured, and that's worth stating: no browser pass. The headless fleet this branch leaned on took the owner's machine down and they asked for no more browsers, so the claim here is structural — a read count from the code, not a frame time. Best confirmed by the owner's eye on prod, or one profiling run when that's welcome again.
🤖 Generated with Claude Code