Skip to content

perf(nav): stop re-measuring the whole page on every scroll frame — fixes the sluggish deck - #51

Merged
IngTian merged 1 commit into
mainfrom
claude/nav-zone-perf
Aug 15, 2026
Merged

perf(nav): stop re-measuring the whole page on every scroll frame — fixes the sluggish deck#51
IngTian merged 1 commit into
mainfrom
claude/nav-zone-perf

Conversation

@IngTian

@IngTian IngTian commented Aug 15, 2026

Copy link
Copy Markdown
Owner

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:

document.querySelectorAll('main > section > *')        a fresh DOM query
nav.getBoundingClientRect()  ×2                       (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 isn't N reads, it's N synchronous layouts per frame. The handler this replaced did one offsetHeight and 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: fixed so its centre line is scroll-invariant. Only scrollY moves.

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 one scrollY read — which does not force layout.

DOM reads per frame: from 1 query + 2 + 2N to zero.

Staleness handled where it actually happens

Rather than re-reading everything constantly:

  • resize re-measures — and scroll no longer shares that handler, since the two sharing it is exactly how the measurement ended up in the scroll path
  • 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 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_FROM threshold. 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 build clean, 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

…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>
@IngTian
IngTian merged commit 9deff03 into main Aug 15, 2026
1 check passed
@IngTian
IngTian deleted the claude/nav-zone-perf branch August 15, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant